Spaces:
Sleeping
Sleeping
Commit ·
639b959
1
Parent(s): 249b12a
feat: Add LinkedIn API posting + enhanced analyzer + schema
Browse files- main.py +42 -0
- models/schemas.py +17 -0
- services/linkedin_analyzer.py +8 -4
- services/linkedin_api.py +132 -0
- services/utils.py +10 -0
main.py
CHANGED
|
@@ -15,6 +15,7 @@ from services.analyzer import analyze_seo_content
|
|
| 15 |
from services.youtube_analyzer import analyze_youtube
|
| 16 |
from services.instagram_analyzer import analyze_instagram
|
| 17 |
from services.linkedin_analyzer import analyze_linkedin
|
|
|
|
| 18 |
from services.facebook_analyzer import analyze_facebook
|
| 19 |
from services.twitter_analyzer import analyze_twitter
|
| 20 |
from services.tiktok_analyzer import analyze_tiktok
|
|
@@ -98,6 +99,47 @@ def analyze_instagram_route(request: SEORequest):
|
|
| 98 |
def analyze_linkedin_route(request: SEORequest):
|
| 99 |
return safe_analyze(analyze_linkedin, request.content)
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
# ---------------------------------------------------------------------------
|
| 102 |
# Facebook
|
| 103 |
# ---------------------------------------------------------------------------
|
|
|
|
| 15 |
from services.youtube_analyzer import analyze_youtube
|
| 16 |
from services.instagram_analyzer import analyze_instagram
|
| 17 |
from services.linkedin_analyzer import analyze_linkedin
|
| 18 |
+
from services.linkedin_api import get_oauth_url, exchange_code, get_user_info, create_post
|
| 19 |
from services.facebook_analyzer import analyze_facebook
|
| 20 |
from services.twitter_analyzer import analyze_twitter
|
| 21 |
from services.tiktok_analyzer import analyze_tiktok
|
|
|
|
| 99 |
def analyze_linkedin_route(request: SEORequest):
|
| 100 |
return safe_analyze(analyze_linkedin, request.content)
|
| 101 |
|
| 102 |
+
|
| 103 |
+
@app.get("/api/linkedin/auth-url")
|
| 104 |
+
def linkedin_auth_url(state: str = ""):
|
| 105 |
+
url = get_oauth_url(state)
|
| 106 |
+
return {"url": url}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class TokenExchangeRequest(BaseModel):
|
| 110 |
+
code: str
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@app.post("/api/linkedin/exchange-token")
|
| 114 |
+
def linkedin_exchange_token(req: TokenExchangeRequest):
|
| 115 |
+
token_data, err = exchange_code(req.code)
|
| 116 |
+
if err:
|
| 117 |
+
raise HTTPException(status_code=400, detail=err)
|
| 118 |
+
user_info, uerr = get_user_info(token_data["access_token"])
|
| 119 |
+
if uerr:
|
| 120 |
+
raise HTTPException(status_code=400, detail=uerr)
|
| 121 |
+
return {
|
| 122 |
+
"access_token": token_data["access_token"],
|
| 123 |
+
"expires_at": token_data["expires_at"],
|
| 124 |
+
"user": user_info,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class PostToLinkedInRequest(BaseModel):
|
| 129 |
+
access_token: str
|
| 130 |
+
author_urn: str
|
| 131 |
+
text: str
|
| 132 |
+
hashtags: list[str] = []
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@app.post("/api/linkedin/post")
|
| 136 |
+
def linkedin_post(req: PostToLinkedInRequest):
|
| 137 |
+
result, err = create_post(req.access_token, req.author_urn, req.text, req.hashtags)
|
| 138 |
+
if err:
|
| 139 |
+
raise HTTPException(status_code=400, detail=err)
|
| 140 |
+
return {"success": True, **result}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
# ---------------------------------------------------------------------------
|
| 144 |
# Facebook
|
| 145 |
# ---------------------------------------------------------------------------
|
models/schemas.py
CHANGED
|
@@ -79,16 +79,33 @@ class PostDraft(BaseModel):
|
|
| 79 |
headline: str
|
| 80 |
body: str
|
| 81 |
hook: str
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
class LinkedInResponse(BaseModel):
|
| 84 |
post_drafts: List[PostDraft]
|
| 85 |
hashtags: List[str]
|
| 86 |
article_topics: List[str]
|
| 87 |
thought_leadership_angles: List[str]
|
|
|
|
|
|
|
| 88 |
best_posting_time: str
|
| 89 |
industry_insights: str
|
| 90 |
error: Optional[str] = None
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
# ---------------------------------------------------------------------------
|
| 93 |
# Facebook
|
| 94 |
# ---------------------------------------------------------------------------
|
|
|
|
| 79 |
headline: str
|
| 80 |
body: str
|
| 81 |
hook: str
|
| 82 |
+
post_type: str = "text"
|
| 83 |
+
call_to_action: Optional[str] = None
|
| 84 |
+
media_suggestion: Optional[str] = None
|
| 85 |
|
| 86 |
class LinkedInResponse(BaseModel):
|
| 87 |
post_drafts: List[PostDraft]
|
| 88 |
hashtags: List[str]
|
| 89 |
article_topics: List[str]
|
| 90 |
thought_leadership_angles: List[str]
|
| 91 |
+
engagement_prompts: List[str] = []
|
| 92 |
+
target_audience_tags: List[str] = []
|
| 93 |
best_posting_time: str
|
| 94 |
industry_insights: str
|
| 95 |
error: Optional[str] = None
|
| 96 |
|
| 97 |
+
class LinkedInPostRequest(BaseModel):
|
| 98 |
+
content: str
|
| 99 |
+
draft_index: int = 0
|
| 100 |
+
access_token: str = ""
|
| 101 |
+
linkedin_urn: str = ""
|
| 102 |
+
|
| 103 |
+
class LinkedInPostResponse(BaseModel):
|
| 104 |
+
success: bool
|
| 105 |
+
post_url: Optional[str] = None
|
| 106 |
+
message: str
|
| 107 |
+
error: Optional[str] = None
|
| 108 |
+
|
| 109 |
# ---------------------------------------------------------------------------
|
| 110 |
# Facebook
|
| 111 |
# ---------------------------------------------------------------------------
|
services/linkedin_analyzer.py
CHANGED
|
@@ -1,12 +1,16 @@
|
|
| 1 |
from services.utils import run_analysis
|
| 2 |
|
| 3 |
-
SYSTEM_PROMPT = """You are a LinkedIn content strategist. Return ONLY a valid JSON object (not an array)."""
|
| 4 |
|
| 5 |
DEFAULTS = {
|
| 6 |
-
"post_drafts": [
|
|
|
|
|
|
|
| 7 |
"hashtags": ["#industry", "#leadership"],
|
| 8 |
"article_topics": ["The Future of This Industry"],
|
| 9 |
"thought_leadership_angles": ["Unique perspective on current trends"],
|
|
|
|
|
|
|
| 10 |
"best_posting_time": "7-9 AM EST Tue-Thu",
|
| 11 |
"industry_insights": "This is a growing space with significant potential."
|
| 12 |
}
|
|
@@ -14,6 +18,6 @@ DEFAULTS = {
|
|
| 14 |
def analyze_linkedin(content: str) -> dict:
|
| 15 |
messages = [
|
| 16 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 17 |
-
{"role": "user", "content": f'Topic: "{content[:1000]}"\nReturn a JSON object (not an array) with these keys:\n- post_drafts (2 items, each with "headline" + "body" + "hook")\n- hashtags (
|
| 18 |
]
|
| 19 |
-
return run_analysis(messages, defaults=DEFAULTS, temperature=0.
|
|
|
|
| 1 |
from services.utils import run_analysis
|
| 2 |
|
| 3 |
+
SYSTEM_PROMPT = """You are a LinkedIn content strategist. Return ONLY a valid JSON object (not an array). Create high-engagement LinkedIn posts with industry insights."""
|
| 4 |
|
| 5 |
DEFAULTS = {
|
| 6 |
+
"post_drafts": [
|
| 7 |
+
{"headline": "Key Insights on This Topic", "body": "Here are my thoughts on this important subject. It's changing how we approach problems in 2026.", "hook": "Stop scrolling if you care about this", "post_type": "text", "call_to_action": "Share your thoughts below", "media_suggestion": "Infographic with key stats"}
|
| 8 |
+
],
|
| 9 |
"hashtags": ["#industry", "#leadership"],
|
| 10 |
"article_topics": ["The Future of This Industry"],
|
| 11 |
"thought_leadership_angles": ["Unique perspective on current trends"],
|
| 12 |
+
"engagement_prompts": ["What has your experience been with this topic?"],
|
| 13 |
+
"target_audience_tags": ["#TechLeaders", "#IndustryPros"],
|
| 14 |
"best_posting_time": "7-9 AM EST Tue-Thu",
|
| 15 |
"industry_insights": "This is a growing space with significant potential."
|
| 16 |
}
|
|
|
|
| 18 |
def analyze_linkedin(content: str) -> dict:
|
| 19 |
messages = [
|
| 20 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 21 |
+
{"role": "user", "content": f'Topic: "{content[:1000]}"\nReturn a JSON object (not an array) with these keys:\n- post_drafts (2 items, each with "headline" + "body" + "hook" + "post_type" ["text"|"article"|"poll"] + "call_to_action" + "media_suggestion")\n- hashtags (4 items)\n- article_topics (2 items)\n- thought_leadership_angles (2 items)\n- engagement_prompts (2 items, questions to spark discussion)\n- target_audience_tags (2 items, industry tags)\n- best_posting_time (string)\n- industry_insights (string)'}
|
| 22 |
]
|
| 23 |
+
return run_analysis(messages, defaults=DEFAULTS, temperature=0.35, max_new_tokens=1000)
|
services/linkedin_api.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import requests
|
| 4 |
+
import time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
env_path = Path(__file__).resolve().parent.parent / ".env"
|
| 9 |
+
load_dotenv(dotenv_path=env_path)
|
| 10 |
+
|
| 11 |
+
CLIENT_ID = os.getenv("LINKEDIN_CLIENT_ID", "")
|
| 12 |
+
CLIENT_SECRET = os.getenv("LINKEDIN_CLIENT_SECRET", "")
|
| 13 |
+
REDIRECT_URI = os.getenv("LINKEDIN_REDIRECT_URI", "http://localhost:8000/linkedin/callback/")
|
| 14 |
+
SCOPE = "w_member_social profile email openid"
|
| 15 |
+
API_BASE = "https://api.linkedin.com"
|
| 16 |
+
AUTH_BASE = "https://www.linkedin.com/oauth/v2"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _log(msg):
|
| 20 |
+
print(f"[LinkedInAPI] {msg}")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def get_oauth_url(state=""):
|
| 24 |
+
params = {
|
| 25 |
+
"response_type": "code",
|
| 26 |
+
"client_id": CLIENT_ID,
|
| 27 |
+
"redirect_uri": REDIRECT_URI,
|
| 28 |
+
"scope": SCOPE,
|
| 29 |
+
}
|
| 30 |
+
if state:
|
| 31 |
+
params["state"] = state
|
| 32 |
+
qs = "&".join(f"{k}={requests.utils.quote(str(v))}" for k, v in params.items())
|
| 33 |
+
return f"{AUTH_BASE}/authorization?{qs}"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def exchange_code(code):
|
| 37 |
+
if not CLIENT_ID or not CLIENT_SECRET:
|
| 38 |
+
return None, "LinkedIn app not configured. Set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET."
|
| 39 |
+
|
| 40 |
+
data = {
|
| 41 |
+
"grant_type": "authorization_code",
|
| 42 |
+
"code": code,
|
| 43 |
+
"client_id": CLIENT_ID,
|
| 44 |
+
"client_secret": CLIENT_SECRET,
|
| 45 |
+
"redirect_uri": REDIRECT_URI,
|
| 46 |
+
}
|
| 47 |
+
try:
|
| 48 |
+
resp = requests.post(f"{AUTH_BASE}/accessToken", data=data, timeout=30)
|
| 49 |
+
if resp.status_code != 200:
|
| 50 |
+
_log(f"Token exchange failed ({resp.status_code}): {resp.text[:200]}")
|
| 51 |
+
return None, f"Failed to get access token: {resp.text[:100]}"
|
| 52 |
+
body = resp.json()
|
| 53 |
+
token = body.get("access_token")
|
| 54 |
+
expires = body.get("expires_in", 86400)
|
| 55 |
+
if not token:
|
| 56 |
+
return None, "No access_token in response"
|
| 57 |
+
_log("Token exchange successful")
|
| 58 |
+
return {"access_token": token, "expires_at": time.time() + expires}, None
|
| 59 |
+
except requests.exceptions.RequestException as e:
|
| 60 |
+
_log(f"Token exchange error: {e}")
|
| 61 |
+
return None, str(e)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_user_info(access_token):
|
| 65 |
+
headers = {"Authorization": f"Bearer {access_token}"}
|
| 66 |
+
try:
|
| 67 |
+
resp = requests.get(f"{API_BASE}/v2/userinfo", headers=headers, timeout=15)
|
| 68 |
+
if resp.status_code != 200:
|
| 69 |
+
_log(f"User info failed ({resp.status_code}): {resp.text[:200]}")
|
| 70 |
+
return None, "Failed to get user info"
|
| 71 |
+
body = resp.json()
|
| 72 |
+
sub = body.get("sub", "")
|
| 73 |
+
name = body.get("name", "")
|
| 74 |
+
picture = body.get("picture", "")
|
| 75 |
+
_log(f"User info: {name} ({sub})")
|
| 76 |
+
return {"urn": f"urn:li:person:{sub}", "name": name, "picture": picture, "sub": sub}, None
|
| 77 |
+
except requests.exceptions.RequestException as e:
|
| 78 |
+
_log(f"User info error: {e}")
|
| 79 |
+
return None, str(e)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def create_post(access_token, author_urn, text, hashtags=None, visibility="PUBLIC"):
|
| 83 |
+
if not access_token or not author_urn:
|
| 84 |
+
return None, "Missing access_token or author_urn"
|
| 85 |
+
|
| 86 |
+
body = {"author": author_urn, "lifecycleState": "PUBLISHED", "visibility": visibility}
|
| 87 |
+
|
| 88 |
+
full_text = text
|
| 89 |
+
if hashtags:
|
| 90 |
+
tag_str = " ".join(f"#{h.lstrip('#')}" for h in hashtags[:5])
|
| 91 |
+
full_text = f"{text}\n\n{tag_str}"
|
| 92 |
+
|
| 93 |
+
body["specificContent"] = {
|
| 94 |
+
"com.linkedin.ugc.ShareContent": {
|
| 95 |
+
"shareCommentary": {"text": full_text},
|
| 96 |
+
"shareMediaCategory": "NONE",
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
headers = {
|
| 101 |
+
"Authorization": f"Bearer {access_token}",
|
| 102 |
+
"Content-Type": "application/json",
|
| 103 |
+
"X-Restli-Protocol-Version": "2.0.0",
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
resp = requests.post(f"{API_BASE}/v2/ugcPosts", json=body, headers=headers, timeout=30)
|
| 108 |
+
if resp.status_code in (200, 201):
|
| 109 |
+
post_id = resp.headers.get("X-RestLi-Id", "")
|
| 110 |
+
post_url = f"https://www.linkedin.com/feed/update/{post_id}" if post_id else ""
|
| 111 |
+
_log(f"Post created: {post_id}")
|
| 112 |
+
return {"post_id": post_id, "post_url": post_url}, None
|
| 113 |
+
|
| 114 |
+
_log(f"Post failed ({resp.status_code}): {resp.text[:300]}")
|
| 115 |
+
|
| 116 |
+
if resp.status_code == 401:
|
| 117 |
+
return None, "Access token expired. Please reconnect LinkedIn."
|
| 118 |
+
if resp.status_code == 403:
|
| 119 |
+
return None, "Missing permissions. Re-authenticate with w_member_social scope."
|
| 120 |
+
if resp.status_code == 429:
|
| 121 |
+
return None, "Rate limited. Try again later."
|
| 122 |
+
|
| 123 |
+
detail = ""
|
| 124 |
+
try:
|
| 125 |
+
detail = resp.json().get("message", resp.text[:100])
|
| 126 |
+
except Exception:
|
| 127 |
+
detail = resp.text[:100]
|
| 128 |
+
return None, f"LinkedIn API error: {detail}"
|
| 129 |
+
|
| 130 |
+
except requests.exceptions.RequestException as e:
|
| 131 |
+
_log(f"Post request error: {e}")
|
| 132 |
+
return None, str(e)
|
services/utils.py
CHANGED
|
@@ -284,6 +284,16 @@ def cross_map_fields(data: dict) -> dict:
|
|
| 284 |
if ideas:
|
| 285 |
data["strategy_tips"] = ideas
|
| 286 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
return data
|
| 288 |
|
| 289 |
|
|
|
|
| 284 |
if ideas:
|
| 285 |
data["strategy_tips"] = ideas
|
| 286 |
|
| 287 |
+
# LinkedIn cross-maps
|
| 288 |
+
if "post_drafts" in data and "content_titles" not in data:
|
| 289 |
+
data["content_titles"] = [d.get("headline", str(d)) for d in data["post_drafts"] if isinstance(d, dict)]
|
| 290 |
+
if "post_drafts" in data and "related_phrases" not in data:
|
| 291 |
+
data["related_phrases"] = [d.get("body", str(d))[:100] for d in data["post_drafts"] if isinstance(d, dict)]
|
| 292 |
+
if "thought_leadership_angles" in data and "strategy_tips" not in data:
|
| 293 |
+
data["strategy_tips"] = data["thought_leadership_angles"]
|
| 294 |
+
if "engagement_prompts" in data and "related_phrases" not in data:
|
| 295 |
+
data["related_phrases"] = data["engagement_prompts"]
|
| 296 |
+
|
| 297 |
return data
|
| 298 |
|
| 299 |
|