Zelyanoth commited on
Commit
cda4ebe
·
1 Parent(s): 6e6661b

feat(linkedin): enhance image handling and improve post publishing

Browse files

- Add support for both file paths and URLs when publishing LinkedIn posts with images
- Implement proper image format detection in image_utils to handle different input types
- Update database schema to include refresh_token for social network accounts
- Improve error handling and logging in LinkedIn service for token exchange and user info fetching
- Add comprehensive tests for keyword trend analyzer component
- Update dependencies to include pytest-cov for test coverage

These changes improve the reliability of image posting to LinkedIn and provide better error handling throughout the service.

Linkedin_poster_dev CHANGED
@@ -1 +1 @@
1
- Subproject commit d84516dcdeb9e85fb0d9c89c19fcc4b02d458168
 
1
+ Subproject commit 556c23cdbb7a7f9f7a2fe03deb092b7efc521653
backend/services/linkedin_service.py CHANGED
@@ -8,20 +8,20 @@ import logging
8
 
9
  class LinkedInService:
10
  """Service for LinkedIn API integration."""
11
-
12
  def __init__(self):
13
  self.client_id = current_app.config['CLIENT_ID']
14
  self.client_secret = current_app.config['CLIENT_SECRET']
15
  self.redirect_uri = current_app.config['REDIRECT_URL']
16
  self.scope = ['openid', 'profile', 'email', 'w_member_social']
17
-
18
  def get_authorization_url(self, state: str) -> str:
19
  """
20
  Get LinkedIn authorization URL.
21
-
22
  Args:
23
  state (str): State parameter for security
24
-
25
  Returns:
26
  str: Authorization URL
27
  """
@@ -31,28 +31,28 @@ class LinkedInService:
31
  scope=self.scope,
32
  state=state
33
  )
34
-
35
  authorization_url, _ = linkedin.authorization_url(
36
  'https://www.linkedin.com/oauth/v2/authorization'
37
  )
38
-
39
  return authorization_url
40
-
41
  def get_access_token(self, code: str) -> dict:
42
  """
43
  Exchange authorization code for access token.
44
-
45
  Args:
46
  code (str): Authorization code
47
-
48
  Returns:
49
  dict: Token response
50
  """
51
  import logging
52
  logger = logging.getLogger(__name__)
53
-
54
  logger.info(f"🔗 [LinkedIn] Starting token exchange for code: {code[:20]}...")
55
-
56
  url = "https://www.linkedin.com/oauth/v2/accessToken"
57
  headers = {
58
  "Content-Type": "application/x-www-form-urlencoded"
@@ -64,94 +64,94 @@ class LinkedInService:
64
  "client_id": self.client_id,
65
  "client_secret": self.client_secret
66
  }
67
-
68
  logger.info(f"🔗 [LinkedIn] Making request to LinkedIn API...")
69
  logger.info(f"🔗 [LinkedIn] Request URL: {url}")
70
  logger.info(f"🔗 [LinkedIn] Request data: {data}")
71
-
72
  try:
73
  response = requests.post(url, headers=headers, data=data)
74
  logger.info(f"🔗 [LinkedIn] Response status: {response.status_code}")
75
  logger.info(f"🔗 [LinkedIn] Response headers: {dict(response.headers)}")
76
-
77
  response.raise_for_status()
78
-
79
  token_data = response.json()
80
  logger.info(f"🔗 [LinkedIn] Token response: {token_data}")
81
-
82
  return token_data
83
  except requests.exceptions.RequestException as e:
84
  logger.error(f"🔗 [LinkedIn] Token exchange failed: {str(e)}")
85
  logger.error(f"🔗 [LinkedIn] Error type: {type(e)}")
86
  raise e
87
-
88
  def get_user_info(self, access_token: str) -> dict:
89
  """
90
  Get user information from LinkedIn.
91
-
92
  Args:
93
  access_token (str): LinkedIn access token
94
-
95
  Returns:
96
  dict: User information
97
  """
98
  import logging
99
  logger = logging.getLogger(__name__)
100
-
101
  logger.info(f"🔗 [LinkedIn] Fetching user info with token length: {len(access_token)}")
102
-
103
  url = "https://api.linkedin.com/v2/userinfo"
104
  headers = {
105
  "Authorization": f"Bearer {access_token}"
106
  }
107
-
108
  logger.info(f"🔗 [LinkedIn] Making request to LinkedIn user info API...")
109
  logger.info(f"🔗 [LinkedIn] Request URL: {url}")
110
  logger.info(f"🔗 [LinkedIn] Request headers: {headers}")
111
-
112
  try:
113
  response = requests.get(url, headers=headers)
114
  logger.info(f"🔗 [LinkedIn] Response status: {response.status_code}")
115
  logger.info(f"🔗 [LinkedIn] Response headers: {dict(response.headers)}")
116
-
117
  response.raise_for_status()
118
-
119
  user_data = response.json()
120
  logger.info(f"🔗 [LinkedIn] User info response: {user_data}")
121
-
122
  return user_data
123
  except requests.exceptions.RequestException as e:
124
  logger.error(f"🔗 [LinkedIn] User info fetch failed: {str(e)}")
125
  logger.error(f"🔗 [LinkedIn] Error type: {type(e)}")
126
  raise e
127
-
128
  def _create_temp_image_file(self, image_bytes: bytes) -> str:
129
  """
130
  Create a temporary file from image bytes.
131
-
132
  Args:
133
  image_bytes: Image data as bytes
134
-
135
  Returns:
136
  Path to the temporary file
137
  """
138
  # Create a temporary file
139
  temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg')
140
  temp_file_path = temp_file.name
141
-
142
  try:
143
  # Write image bytes to the temporary file
144
  temp_file.write(image_bytes)
145
  temp_file.flush()
146
  finally:
147
  temp_file.close()
148
-
149
  return temp_file_path
150
-
151
  def _cleanup_temp_file(self, file_path: str) -> None:
152
  """
153
  Safely remove a temporary file.
154
-
155
  Args:
156
  file_path: Path to the temporary file to remove
157
  """
@@ -161,17 +161,17 @@ class LinkedInService:
161
  except Exception as e:
162
  # Log the error but don't fail the operation
163
  logging.error(f"Failed to cleanup temporary file {file_path}: {str(e)}")
164
-
165
  def publish_post(self, access_token: str, user_id: str, text_content: str, image_url: str = None) -> dict:
166
  """
167
  Publish a post to LinkedIn.
168
-
169
  Args:
170
  access_token (str): LinkedIn access token
171
  user_id (str): LinkedIn user ID
172
  text_content (str): Post content
173
  image_url (str or bytes, optional): Image URL or image bytes
174
-
175
  Returns:
176
  dict: Publish response
177
  """
@@ -182,12 +182,12 @@ class LinkedInService:
182
  "X-Restli-Protocol-Version": "2.0.0",
183
  "Content-Type": "application/json"
184
  }
185
-
186
  try:
187
  if image_url and isinstance(image_url, bytes):
188
  # Handle bytes data - create temporary file and upload
189
  temp_file_path = self._create_temp_image_file(image_url)
190
-
191
  # Register upload
192
  register_body = {
193
  "registerUploadRequest": {
@@ -199,33 +199,33 @@ class LinkedInService:
199
  }]
200
  }
201
  }
202
-
203
  r = requests.post(
204
  "https://api.linkedin.com/v2/assets?action=registerUpload",
205
  headers=headers,
206
  json=register_body
207
  )
208
-
209
  if r.status_code not in (200, 201):
210
  raise Exception(f"Failed to register upload: {r.status_code} {r.text}")
211
-
212
  datar = r.json()["value"]
213
  upload_url = datar["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"]
214
  asset_urn = datar["asset"]
215
-
216
  # Upload image from temporary file
217
  upload_headers = {
218
  "Authorization": f"Bearer {access_token}",
219
  "X-Restli-Protocol-Version": "2.0.0",
220
  "Content-Type": "application/octet-stream"
221
  }
222
-
223
  with open(temp_file_path, 'rb') as f:
224
  image_data = f.read()
225
  upload_response = requests.put(upload_url, headers=upload_headers, data=image_data)
226
  if upload_response.status_code not in (200, 201):
227
  raise Exception(f"Failed to upload image: {upload_response.status_code} {upload_response.text}")
228
-
229
  # Create post with image
230
  post_body = {
231
  "author": f"urn:li:person:{user_id}",
@@ -245,7 +245,7 @@ class LinkedInService:
245
  "visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"}
246
  }
247
  elif image_url and isinstance(image_url, str):
248
- # Handle image upload for URL-based images
249
  register_body = {
250
  "registerUploadRequest": {
251
  "recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
@@ -256,34 +256,45 @@ class LinkedInService:
256
  }]
257
  }
258
  }
259
-
260
  r = requests.post(
261
  "https://api.linkedin.com/v2/assets?action=registerUpload",
262
  headers=headers,
263
  json=register_body
264
  )
265
-
266
  if r.status_code not in (200, 201):
267
  raise Exception(f"Failed to register upload: {r.status_code} {r.text}")
268
-
269
  datar = r.json()["value"]
270
  upload_url = datar["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"]
271
  asset_urn = datar["asset"]
272
-
273
  # Upload image
274
  upload_headers = {
275
  "Authorization": f"Bearer {access_token}",
276
  "X-Restli-Protocol-Version": "2.0.0",
277
  "Content-Type": "application/octet-stream"
278
  }
279
-
280
- # Download image and upload to LinkedIn
281
- image_response = requests.get(image_url)
282
- if image_response.status_code == 200:
283
- upload_response = requests.put(upload_url, headers=upload_headers, data=image_response.content)
284
- if upload_response.status_code not in (200, 201):
285
- raise Exception(f"Failed to upload image: {upload_response.status_code} {upload_response.text}")
286
-
 
 
 
 
 
 
 
 
 
 
 
287
  # Create post with image
288
  post_body = {
289
  "author": f"urn:li:person:{user_id}",
@@ -319,7 +330,7 @@ class LinkedInService:
319
  "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
320
  }
321
  }
322
-
323
  response = requests.post(url, headers=headers, json=post_body)
324
  response.raise_for_status()
325
  return response.json()
 
8
 
9
  class LinkedInService:
10
  """Service for LinkedIn API integration."""
11
+
12
  def __init__(self):
13
  self.client_id = current_app.config['CLIENT_ID']
14
  self.client_secret = current_app.config['CLIENT_SECRET']
15
  self.redirect_uri = current_app.config['REDIRECT_URL']
16
  self.scope = ['openid', 'profile', 'email', 'w_member_social']
17
+
18
  def get_authorization_url(self, state: str) -> str:
19
  """
20
  Get LinkedIn authorization URL.
21
+
22
  Args:
23
  state (str): State parameter for security
24
+
25
  Returns:
26
  str: Authorization URL
27
  """
 
31
  scope=self.scope,
32
  state=state
33
  )
34
+
35
  authorization_url, _ = linkedin.authorization_url(
36
  'https://www.linkedin.com/oauth/v2/authorization'
37
  )
38
+
39
  return authorization_url
40
+
41
  def get_access_token(self, code: str) -> dict:
42
  """
43
  Exchange authorization code for access token.
44
+
45
  Args:
46
  code (str): Authorization code
47
+
48
  Returns:
49
  dict: Token response
50
  """
51
  import logging
52
  logger = logging.getLogger(__name__)
53
+
54
  logger.info(f"🔗 [LinkedIn] Starting token exchange for code: {code[:20]}...")
55
+
56
  url = "https://www.linkedin.com/oauth/v2/accessToken"
57
  headers = {
58
  "Content-Type": "application/x-www-form-urlencoded"
 
64
  "client_id": self.client_id,
65
  "client_secret": self.client_secret
66
  }
67
+
68
  logger.info(f"🔗 [LinkedIn] Making request to LinkedIn API...")
69
  logger.info(f"🔗 [LinkedIn] Request URL: {url}")
70
  logger.info(f"🔗 [LinkedIn] Request data: {data}")
71
+
72
  try:
73
  response = requests.post(url, headers=headers, data=data)
74
  logger.info(f"🔗 [LinkedIn] Response status: {response.status_code}")
75
  logger.info(f"🔗 [LinkedIn] Response headers: {dict(response.headers)}")
76
+
77
  response.raise_for_status()
78
+
79
  token_data = response.json()
80
  logger.info(f"🔗 [LinkedIn] Token response: {token_data}")
81
+
82
  return token_data
83
  except requests.exceptions.RequestException as e:
84
  logger.error(f"🔗 [LinkedIn] Token exchange failed: {str(e)}")
85
  logger.error(f"🔗 [LinkedIn] Error type: {type(e)}")
86
  raise e
87
+
88
  def get_user_info(self, access_token: str) -> dict:
89
  """
90
  Get user information from LinkedIn.
91
+
92
  Args:
93
  access_token (str): LinkedIn access token
94
+
95
  Returns:
96
  dict: User information
97
  """
98
  import logging
99
  logger = logging.getLogger(__name__)
100
+
101
  logger.info(f"🔗 [LinkedIn] Fetching user info with token length: {len(access_token)}")
102
+
103
  url = "https://api.linkedin.com/v2/userinfo"
104
  headers = {
105
  "Authorization": f"Bearer {access_token}"
106
  }
107
+
108
  logger.info(f"🔗 [LinkedIn] Making request to LinkedIn user info API...")
109
  logger.info(f"🔗 [LinkedIn] Request URL: {url}")
110
  logger.info(f"🔗 [LinkedIn] Request headers: {headers}")
111
+
112
  try:
113
  response = requests.get(url, headers=headers)
114
  logger.info(f"🔗 [LinkedIn] Response status: {response.status_code}")
115
  logger.info(f"🔗 [LinkedIn] Response headers: {dict(response.headers)}")
116
+
117
  response.raise_for_status()
118
+
119
  user_data = response.json()
120
  logger.info(f"🔗 [LinkedIn] User info response: {user_data}")
121
+
122
  return user_data
123
  except requests.exceptions.RequestException as e:
124
  logger.error(f"🔗 [LinkedIn] User info fetch failed: {str(e)}")
125
  logger.error(f"🔗 [LinkedIn] Error type: {type(e)}")
126
  raise e
127
+
128
  def _create_temp_image_file(self, image_bytes: bytes) -> str:
129
  """
130
  Create a temporary file from image bytes.
131
+
132
  Args:
133
  image_bytes: Image data as bytes
134
+
135
  Returns:
136
  Path to the temporary file
137
  """
138
  # Create a temporary file
139
  temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg')
140
  temp_file_path = temp_file.name
141
+
142
  try:
143
  # Write image bytes to the temporary file
144
  temp_file.write(image_bytes)
145
  temp_file.flush()
146
  finally:
147
  temp_file.close()
148
+
149
  return temp_file_path
150
+
151
  def _cleanup_temp_file(self, file_path: str) -> None:
152
  """
153
  Safely remove a temporary file.
154
+
155
  Args:
156
  file_path: Path to the temporary file to remove
157
  """
 
161
  except Exception as e:
162
  # Log the error but don't fail the operation
163
  logging.error(f"Failed to cleanup temporary file {file_path}: {str(e)}")
164
+
165
  def publish_post(self, access_token: str, user_id: str, text_content: str, image_url: str = None) -> dict:
166
  """
167
  Publish a post to LinkedIn.
168
+
169
  Args:
170
  access_token (str): LinkedIn access token
171
  user_id (str): LinkedIn user ID
172
  text_content (str): Post content
173
  image_url (str or bytes, optional): Image URL or image bytes
174
+
175
  Returns:
176
  dict: Publish response
177
  """
 
182
  "X-Restli-Protocol-Version": "2.0.0",
183
  "Content-Type": "application/json"
184
  }
185
+
186
  try:
187
  if image_url and isinstance(image_url, bytes):
188
  # Handle bytes data - create temporary file and upload
189
  temp_file_path = self._create_temp_image_file(image_url)
190
+
191
  # Register upload
192
  register_body = {
193
  "registerUploadRequest": {
 
199
  }]
200
  }
201
  }
202
+
203
  r = requests.post(
204
  "https://api.linkedin.com/v2/assets?action=registerUpload",
205
  headers=headers,
206
  json=register_body
207
  )
208
+
209
  if r.status_code not in (200, 201):
210
  raise Exception(f"Failed to register upload: {r.status_code} {r.text}")
211
+
212
  datar = r.json()["value"]
213
  upload_url = datar["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"]
214
  asset_urn = datar["asset"]
215
+
216
  # Upload image from temporary file
217
  upload_headers = {
218
  "Authorization": f"Bearer {access_token}",
219
  "X-Restli-Protocol-Version": "2.0.0",
220
  "Content-Type": "application/octet-stream"
221
  }
222
+
223
  with open(temp_file_path, 'rb') as f:
224
  image_data = f.read()
225
  upload_response = requests.put(upload_url, headers=upload_headers, data=image_data)
226
  if upload_response.status_code not in (200, 201):
227
  raise Exception(f"Failed to upload image: {upload_response.status_code} {upload_response.text}")
228
+
229
  # Create post with image
230
  post_body = {
231
  "author": f"urn:li:person:{user_id}",
 
245
  "visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"}
246
  }
247
  elif image_url and isinstance(image_url, str):
248
+ # Handle image upload - check if it's a file path or URL
249
  register_body = {
250
  "registerUploadRequest": {
251
  "recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
 
256
  }]
257
  }
258
  }
259
+
260
  r = requests.post(
261
  "https://api.linkedin.com/v2/assets?action=registerUpload",
262
  headers=headers,
263
  json=register_body
264
  )
265
+
266
  if r.status_code not in (200, 201):
267
  raise Exception(f"Failed to register upload: {r.status_code} {r.text}")
268
+
269
  datar = r.json()["value"]
270
  upload_url = datar["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"]
271
  asset_urn = datar["asset"]
272
+
273
  # Upload image
274
  upload_headers = {
275
  "Authorization": f"Bearer {access_token}",
276
  "X-Restli-Protocol-Version": "2.0.0",
277
  "Content-Type": "application/octet-stream"
278
  }
279
+
280
+ # Check if image_url is a file path or a URL
281
+ import os
282
+ if os.path.exists(image_url) and not image_url.startswith(('http://', 'https://', 'ftp://', 'file://')):
283
+ # It's a local file path, read the file
284
+ with open(image_url, 'rb') as f:
285
+ image_content = f.read()
286
+ else:
287
+ # It's a URL, download the image
288
+ image_response = requests.get(image_url)
289
+ if image_response.status_code != 200:
290
+ raise Exception(f"Failed to download image from URL: {image_response.status_code} {image_response.text}")
291
+ image_content = image_response.content
292
+
293
+ # Upload to LinkedIn
294
+ upload_response = requests.put(upload_url, headers=upload_headers, data=image_content)
295
+ if upload_response.status_code not in (200, 201):
296
+ raise Exception(f"Failed to upload image: {upload_response.status_code} {upload_response.text}")
297
+
298
  # Create post with image
299
  post_body = {
300
  "author": f"urn:li:person:{user_id}",
 
330
  "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
331
  }
332
  }
333
+
334
  response = requests.post(url, headers=headers, json=post_body)
335
  response.raise_for_status()
336
  return response.json()
backend/utils/image_utils.py CHANGED
@@ -1,39 +1,50 @@
1
  """Utility functions for image handling and processing."""
2
 
3
  import base64
 
4
 
5
 
6
  def ensure_bytes_format(image_data):
7
  """
8
  Ensure image data is in the proper format for storage.
9
-
 
10
  Args:
11
- image_data: Image data that could be bytes, base64 string, or URL string
12
-
13
  Returns:
14
- Properly formatted data for database storage
15
  """
16
  if image_data is None:
17
  return None
18
-
19
- # If it's already bytes, return as is
20
  if isinstance(image_data, bytes):
21
  return image_data
22
-
23
- # If it's a string, check if it's base64 encoded
24
  if isinstance(image_data, str):
25
- # Check if it's a data URL
26
  if image_data.startswith('data:image/'):
27
  try:
28
  # Extract base64 part and decode to bytes
29
  base64_part = image_data.split(',')[1]
30
  return base64.b64decode(base64_part)
31
  except Exception:
32
- # If decoding fails, store as string (URL)
33
  return image_data
 
 
 
 
 
 
 
 
 
34
  else:
35
- # Assume it's a URL, store as string
36
  return image_data
37
-
38
  # For any other type, return as is
39
  return image_data
 
1
  """Utility functions for image handling and processing."""
2
 
3
  import base64
4
+ import re
5
 
6
 
7
  def ensure_bytes_format(image_data):
8
  """
9
  Ensure image data is in the proper format for storage.
10
+ File paths and URLs should remain as strings, only actual image data should be bytes.
11
+
12
  Args:
13
+ image_data: Image data that could be bytes, base64 string, file path, or URL string
14
+
15
  Returns:
16
+ Properly formatted data for database storage (bytes for actual image data, string for paths/URLs)
17
  """
18
  if image_data is None:
19
  return None
20
+
21
+ # If it's already bytes, return as is (for actual image data)
22
  if isinstance(image_data, bytes):
23
  return image_data
24
+
25
+ # If it's a string, determine if it's a file path, URL, or base64 data
26
  if isinstance(image_data, str):
27
+ # Check if it's a data URL (base64 encoded image)
28
  if image_data.startswith('data:image/'):
29
  try:
30
  # Extract base64 part and decode to bytes
31
  base64_part = image_data.split(',')[1]
32
  return base64.b64decode(base64_part)
33
  except Exception:
34
+ # If decoding fails, return the original string
35
  return image_data
36
+ # Check if it looks like a file path
37
+ # This regex matches common file path patterns like /tmp/..., ./path/, ../path/, C:\path\, etc.
38
+ elif re.match(r'^[/\\]|[.][/\\]|[\w]:[/\\]|.*\.(jpg|jpeg|png|gif|webp|bmp|tiff|svg)$', image_data.lower()):
39
+ # This appears to be a file path, return as string - don't convert to bytes
40
+ return image_data
41
+ # Check if it looks like a URL (has a scheme like http:// or https://)
42
+ elif image_data.startswith(('http://', 'https://', 'ftp://', 'file://')):
43
+ # This is a URL, return as string
44
+ return image_data
45
  else:
46
+ # Assume it's a string that should remain as is
47
  return image_data
48
+
49
  # For any other type, return as is
50
  return image_data
docu_code/My_data_base_schema_.txt CHANGED
@@ -5,7 +5,7 @@ CREATE TABLE public.Post_content (
5
  id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
6
  id_social bigint,
7
  Text_content text,
8
- image_content_url bytea,
9
  Video_content text,
10
  post_time time without time zone,
11
  created_at timestamp with time zone NOT NULL DEFAULT now(),
@@ -36,6 +36,7 @@ CREATE TABLE public.Social_network (
36
  family_name character varying NOT NULL,
37
  account_name text NOT NULL UNIQUE,
38
  expiration date,
 
39
  CONSTRAINT Social_network_pkey PRIMARY KEY (id),
40
  CONSTRAINT Social_network_id_utilisateur_fkey FOREIGN KEY (id_utilisateur) REFERENCES auth.users(id)
41
  );
 
5
  id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
6
  id_social bigint,
7
  Text_content text,
8
+ image_content_url character varying,
9
  Video_content text,
10
  post_time time without time zone,
11
  created_at timestamp with time zone NOT NULL DEFAULT now(),
 
36
  family_name character varying NOT NULL,
37
  account_name text NOT NULL UNIQUE,
38
  expiration date,
39
+ refresh_token character varying,
40
  CONSTRAINT Social_network_pkey PRIMARY KEY (id),
41
  CONSTRAINT Social_network_id_utilisateur_fkey FOREIGN KEY (id_utilisateur) REFERENCES auth.users(id)
42
  );