ihtesham0345 commited on
Commit
639b959
·
1 Parent(s): 249b12a

feat: Add LinkedIn API posting + enhanced analyzer + schema

Browse files
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": [{"headline": "Key Insights on This Topic", "body": "Here are my thoughts on this important subject.", "hook": "Stop scrolling if you care about this"}],
 
 
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 (3 items)\n- article_topics (2 items)\n- thought_leadership_angles (2 items)\n- best_posting_time (string)\n- industry_insights (string)'}
18
  ]
19
- return run_analysis(messages, defaults=DEFAULTS, temperature=0.3, max_new_tokens=800)
 
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