marqetive-lib 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- marqetive/__init__.py +113 -0
- marqetive/core/__init__.py +5 -0
- marqetive/core/account_factory.py +212 -0
- marqetive/core/base_manager.py +303 -0
- marqetive/core/client.py +108 -0
- marqetive/core/progress.py +291 -0
- marqetive/core/registry.py +257 -0
- marqetive/platforms/__init__.py +55 -0
- marqetive/platforms/base.py +390 -0
- marqetive/platforms/exceptions.py +238 -0
- marqetive/platforms/instagram/__init__.py +7 -0
- marqetive/platforms/instagram/client.py +786 -0
- marqetive/platforms/instagram/exceptions.py +311 -0
- marqetive/platforms/instagram/factory.py +106 -0
- marqetive/platforms/instagram/manager.py +112 -0
- marqetive/platforms/instagram/media.py +669 -0
- marqetive/platforms/linkedin/__init__.py +7 -0
- marqetive/platforms/linkedin/client.py +733 -0
- marqetive/platforms/linkedin/exceptions.py +335 -0
- marqetive/platforms/linkedin/factory.py +130 -0
- marqetive/platforms/linkedin/manager.py +119 -0
- marqetive/platforms/linkedin/media.py +549 -0
- marqetive/platforms/models.py +345 -0
- marqetive/platforms/tiktok/__init__.py +0 -0
- marqetive/platforms/twitter/__init__.py +7 -0
- marqetive/platforms/twitter/client.py +647 -0
- marqetive/platforms/twitter/exceptions.py +311 -0
- marqetive/platforms/twitter/factory.py +151 -0
- marqetive/platforms/twitter/manager.py +121 -0
- marqetive/platforms/twitter/media.py +779 -0
- marqetive/platforms/twitter/threads.py +442 -0
- marqetive/py.typed +0 -0
- marqetive/registry_init.py +66 -0
- marqetive/utils/__init__.py +45 -0
- marqetive/utils/file_handlers.py +438 -0
- marqetive/utils/helpers.py +99 -0
- marqetive/utils/media.py +399 -0
- marqetive/utils/oauth.py +265 -0
- marqetive/utils/retry.py +239 -0
- marqetive/utils/token_validator.py +240 -0
- marqetive_lib-0.1.0.dist-info/METADATA +261 -0
- marqetive_lib-0.1.0.dist-info/RECORD +43 -0
- marqetive_lib-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""LinkedIn API client implementation.
|
|
2
|
+
|
|
3
|
+
This module provides a concrete implementation of the SocialMediaPlatform
|
|
4
|
+
ABC for LinkedIn, using the LinkedIn Marketing API and Share API.
|
|
5
|
+
|
|
6
|
+
API Documentation: https://learn.microsoft.com/en-us/linkedin/
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import HttpUrl
|
|
14
|
+
|
|
15
|
+
from marqetive.platforms.base import SocialMediaPlatform
|
|
16
|
+
from marqetive.platforms.exceptions import (
|
|
17
|
+
MediaUploadError,
|
|
18
|
+
PlatformAuthError,
|
|
19
|
+
PlatformError,
|
|
20
|
+
PostNotFoundError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
from marqetive.platforms.linkedin.media import LinkedInMediaManager, MediaAsset
|
|
24
|
+
from marqetive.platforms.models import (
|
|
25
|
+
AuthCredentials,
|
|
26
|
+
Comment,
|
|
27
|
+
CommentStatus,
|
|
28
|
+
MediaAttachment,
|
|
29
|
+
MediaType,
|
|
30
|
+
Post,
|
|
31
|
+
PostCreateRequest,
|
|
32
|
+
PostStatus,
|
|
33
|
+
PostUpdateRequest,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LinkedInClient(SocialMediaPlatform):
|
|
38
|
+
"""LinkedIn API client.
|
|
39
|
+
|
|
40
|
+
This client implements the SocialMediaPlatform interface for LinkedIn,
|
|
41
|
+
using the LinkedIn Share API and Marketing API. It supports creating
|
|
42
|
+
posts (shares), managing comments, and uploading media.
|
|
43
|
+
|
|
44
|
+
Note:
|
|
45
|
+
- Requires LinkedIn Developer app with appropriate permissions
|
|
46
|
+
- Requires OAuth 2.0 authentication
|
|
47
|
+
- Supports both personal profiles and organization pages
|
|
48
|
+
- Rate limits vary by API endpoint
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
>>> credentials = AuthCredentials(
|
|
52
|
+
... platform="linkedin",
|
|
53
|
+
... access_token="your_access_token",
|
|
54
|
+
... user_id="urn:li:person:abc123"
|
|
55
|
+
... )
|
|
56
|
+
>>> async with LinkedInClient(credentials) as client:
|
|
57
|
+
... request = PostCreateRequest(
|
|
58
|
+
... content="Excited to share our latest update!",
|
|
59
|
+
... link="https://example.com"
|
|
60
|
+
... )
|
|
61
|
+
... post = await client.create_post(request)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
credentials: AuthCredentials,
|
|
67
|
+
timeout: float = 30.0,
|
|
68
|
+
api_version: str = "v2",
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Initialize LinkedIn client.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
credentials: LinkedIn authentication credentials
|
|
74
|
+
timeout: Request timeout in seconds
|
|
75
|
+
api_version: LinkedIn API version
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
PlatformAuthError: If credentials are invalid
|
|
79
|
+
"""
|
|
80
|
+
base_url = f"https://api.linkedin.com/{api_version}"
|
|
81
|
+
super().__init__(
|
|
82
|
+
platform_name="linkedin",
|
|
83
|
+
credentials=credentials,
|
|
84
|
+
base_url=base_url,
|
|
85
|
+
timeout=timeout,
|
|
86
|
+
)
|
|
87
|
+
self.author_urn = (
|
|
88
|
+
credentials.user_id
|
|
89
|
+
) # urn:li:person:xxx or urn:li:organization:xxx
|
|
90
|
+
self.api_version = api_version
|
|
91
|
+
|
|
92
|
+
# Media manager (initialized in __aenter__)
|
|
93
|
+
self._media_manager: LinkedInMediaManager | None = None
|
|
94
|
+
|
|
95
|
+
async def __aenter__(self) -> "LinkedInClient":
|
|
96
|
+
"""Async context manager entry."""
|
|
97
|
+
await super().__aenter__()
|
|
98
|
+
|
|
99
|
+
# Initialize media manager
|
|
100
|
+
if not self.author_urn:
|
|
101
|
+
raise PlatformAuthError(
|
|
102
|
+
"LinkedIn author URN (user_id) is required in credentials",
|
|
103
|
+
platform=self.platform_name,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self._media_manager = LinkedInMediaManager(
|
|
107
|
+
person_urn=self.author_urn,
|
|
108
|
+
access_token=self.credentials.access_token,
|
|
109
|
+
api_version=self.api_version,
|
|
110
|
+
timeout=self.timeout,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
116
|
+
"""Async context manager exit."""
|
|
117
|
+
# Cleanup media manager
|
|
118
|
+
if self._media_manager:
|
|
119
|
+
await self._media_manager.__aexit__(exc_type, exc_val, exc_tb)
|
|
120
|
+
self._media_manager = None
|
|
121
|
+
|
|
122
|
+
await super().__aexit__(exc_type, exc_val, exc_tb)
|
|
123
|
+
|
|
124
|
+
# ==================== Authentication Methods ====================
|
|
125
|
+
|
|
126
|
+
async def authenticate(self) -> AuthCredentials:
|
|
127
|
+
"""Perform LinkedIn authentication flow.
|
|
128
|
+
|
|
129
|
+
Note: This method assumes you already have a valid OAuth 2.0 access token.
|
|
130
|
+
For the full OAuth flow, use LinkedIn's OAuth 2.0 implementation.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Current credentials if valid.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
PlatformAuthError: If authentication fails.
|
|
137
|
+
"""
|
|
138
|
+
if await self.is_authenticated():
|
|
139
|
+
return self.credentials
|
|
140
|
+
|
|
141
|
+
raise PlatformAuthError(
|
|
142
|
+
"Invalid or expired credentials. Please re-authenticate via LinkedIn OAuth 2.0.",
|
|
143
|
+
platform=self.platform_name,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def refresh_token(self) -> AuthCredentials:
|
|
147
|
+
"""Refresh LinkedIn access token.
|
|
148
|
+
|
|
149
|
+
LinkedIn access tokens typically expire after 60 days. Use the
|
|
150
|
+
refresh token to obtain a new access token.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Updated credentials with new access token.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
PlatformAuthError: If token refresh fails.
|
|
157
|
+
"""
|
|
158
|
+
if not self.credentials.refresh_token:
|
|
159
|
+
raise PlatformAuthError(
|
|
160
|
+
"No refresh token available",
|
|
161
|
+
platform=self.platform_name,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Note: LinkedIn OAuth token refresh requires making a request to
|
|
165
|
+
# https://www.linkedin.com/oauth/v2/accessToken
|
|
166
|
+
# This is simplified for demonstration
|
|
167
|
+
raise PlatformAuthError(
|
|
168
|
+
"Token refresh not yet implemented. Please re-authenticate.",
|
|
169
|
+
platform=self.platform_name,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def is_authenticated(self) -> bool:
|
|
173
|
+
"""Check if LinkedIn credentials are valid.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
True if authenticated and token is valid.
|
|
177
|
+
"""
|
|
178
|
+
if not self.api_client:
|
|
179
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
# Verify credentials by fetching user profile
|
|
183
|
+
await self.api_client.get("/me")
|
|
184
|
+
return True
|
|
185
|
+
except httpx.HTTPError:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
# ==================== Post CRUD Methods ====================
|
|
189
|
+
|
|
190
|
+
async def create_post(self, request: PostCreateRequest) -> Post:
|
|
191
|
+
"""Create and publish a LinkedIn post (share).
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
request: Post creation request.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Created Post object.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
ValidationError: If request is invalid.
|
|
201
|
+
MediaUploadError: If media upload fails.
|
|
202
|
+
"""
|
|
203
|
+
if not self.api_client:
|
|
204
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
205
|
+
|
|
206
|
+
if not request.content:
|
|
207
|
+
raise ValidationError(
|
|
208
|
+
"LinkedIn posts require content",
|
|
209
|
+
platform=self.platform_name,
|
|
210
|
+
field="content",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Validate content length (3000 characters for posts)
|
|
214
|
+
if len(request.content) > 3000:
|
|
215
|
+
raise ValidationError(
|
|
216
|
+
f"Post content exceeds 3000 characters ({len(request.content)} characters)",
|
|
217
|
+
platform=self.platform_name,
|
|
218
|
+
field="content",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
# Build share payload
|
|
223
|
+
share_payload: dict[str, Any] = {
|
|
224
|
+
"author": self.author_urn,
|
|
225
|
+
"lifecycleState": "PUBLISHED",
|
|
226
|
+
"specificContent": {
|
|
227
|
+
"com.linkedin.ugc.ShareContent": {
|
|
228
|
+
"shareCommentary": {"text": request.content},
|
|
229
|
+
"shareMediaCategory": "NONE",
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# Add media if provided
|
|
236
|
+
if request.media_ids:
|
|
237
|
+
share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
|
|
238
|
+
"shareMediaCategory"
|
|
239
|
+
] = "IMAGE"
|
|
240
|
+
share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
|
|
241
|
+
"media"
|
|
242
|
+
] = [
|
|
243
|
+
{"status": "READY", "media": media_id}
|
|
244
|
+
for media_id in request.media_ids
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
# Add link if provided
|
|
248
|
+
if request.link:
|
|
249
|
+
share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
|
|
250
|
+
"shareMediaCategory"
|
|
251
|
+
] = "ARTICLE"
|
|
252
|
+
share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
|
|
253
|
+
"media"
|
|
254
|
+
] = [
|
|
255
|
+
{
|
|
256
|
+
"status": "READY",
|
|
257
|
+
"originalUrl": request.link,
|
|
258
|
+
}
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
# Create the share
|
|
262
|
+
response = await self.api_client.post("/ugcPosts", data=share_payload)
|
|
263
|
+
|
|
264
|
+
post_id = response.data["id"]
|
|
265
|
+
|
|
266
|
+
# Fetch full post details
|
|
267
|
+
return await self.get_post(post_id)
|
|
268
|
+
|
|
269
|
+
except httpx.HTTPError as e:
|
|
270
|
+
raise PlatformError(
|
|
271
|
+
f"Failed to create LinkedIn post: {e}",
|
|
272
|
+
platform=self.platform_name,
|
|
273
|
+
) from e
|
|
274
|
+
|
|
275
|
+
async def get_post(self, post_id: str) -> Post:
|
|
276
|
+
"""Retrieve a LinkedIn post by ID.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
post_id: LinkedIn post URN (e.g., urn:li:share:123).
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Post object with current data.
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
PostNotFoundError: If post doesn't exist.
|
|
286
|
+
"""
|
|
287
|
+
if not self.api_client:
|
|
288
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
response = await self.api_client.get(f"/ugcPosts/{post_id}")
|
|
292
|
+
data = response.data
|
|
293
|
+
return self._parse_post(data)
|
|
294
|
+
|
|
295
|
+
except httpx.HTTPStatusError as e:
|
|
296
|
+
if e.response.status_code == 404:
|
|
297
|
+
raise PostNotFoundError(
|
|
298
|
+
post_id=post_id,
|
|
299
|
+
platform=self.platform_name,
|
|
300
|
+
status_code=404,
|
|
301
|
+
) from e
|
|
302
|
+
raise PlatformError(
|
|
303
|
+
f"Failed to fetch post: {e}",
|
|
304
|
+
platform=self.platform_name,
|
|
305
|
+
) from e
|
|
306
|
+
except httpx.HTTPError as e:
|
|
307
|
+
raise PlatformError(
|
|
308
|
+
f"Failed to fetch post: {e}",
|
|
309
|
+
platform=self.platform_name,
|
|
310
|
+
) from e
|
|
311
|
+
|
|
312
|
+
async def update_post(
|
|
313
|
+
self,
|
|
314
|
+
post_id: str, # noqa: ARG002
|
|
315
|
+
request: PostUpdateRequest, # noqa: ARG002
|
|
316
|
+
) -> Post:
|
|
317
|
+
"""Update a LinkedIn post.
|
|
318
|
+
|
|
319
|
+
Note: LinkedIn has limited support for editing posts. Only certain
|
|
320
|
+
fields can be updated, and there are time restrictions.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
post_id: LinkedIn post URN.
|
|
324
|
+
request: Post update request.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
PlatformError: If post cannot be edited.
|
|
328
|
+
"""
|
|
329
|
+
raise PlatformError(
|
|
330
|
+
"LinkedIn does not support editing published posts",
|
|
331
|
+
platform=self.platform_name,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
async def delete_post(self, post_id: str) -> bool:
|
|
335
|
+
"""Delete a LinkedIn post.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
post_id: LinkedIn post URN.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
True if deletion was successful.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
PostNotFoundError: If post doesn't exist.
|
|
345
|
+
"""
|
|
346
|
+
if not self.api_client:
|
|
347
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
# LinkedIn uses DELETE method for removing posts
|
|
351
|
+
if not self.api_client._client:
|
|
352
|
+
raise RuntimeError("API client not initialized")
|
|
353
|
+
|
|
354
|
+
await self.api_client._client.delete(f"{self.base_url}/ugcPosts/{post_id}")
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
except httpx.HTTPStatusError as e:
|
|
358
|
+
if e.response.status_code == 404:
|
|
359
|
+
raise PostNotFoundError(
|
|
360
|
+
post_id=post_id,
|
|
361
|
+
platform=self.platform_name,
|
|
362
|
+
status_code=404,
|
|
363
|
+
) from e
|
|
364
|
+
raise PlatformError(
|
|
365
|
+
f"Failed to delete post: {e}",
|
|
366
|
+
platform=self.platform_name,
|
|
367
|
+
) from e
|
|
368
|
+
except httpx.HTTPError as e:
|
|
369
|
+
raise PlatformError(
|
|
370
|
+
f"Failed to delete post: {e}",
|
|
371
|
+
platform=self.platform_name,
|
|
372
|
+
) from e
|
|
373
|
+
|
|
374
|
+
# ==================== Comment Methods ====================
|
|
375
|
+
|
|
376
|
+
async def get_comments(
|
|
377
|
+
self,
|
|
378
|
+
post_id: str,
|
|
379
|
+
limit: int = 50,
|
|
380
|
+
offset: int = 0,
|
|
381
|
+
) -> list[Comment]:
|
|
382
|
+
"""Retrieve comments for a LinkedIn post.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
post_id: LinkedIn post URN.
|
|
386
|
+
limit: Maximum number of comments to retrieve.
|
|
387
|
+
offset: Number of comments to skip.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
List of Comment objects.
|
|
391
|
+
"""
|
|
392
|
+
if not self.api_client:
|
|
393
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
# LinkedIn Social API endpoint for comments
|
|
397
|
+
response = await self.api_client.get(
|
|
398
|
+
f"/socialActions/{post_id}/comments",
|
|
399
|
+
params={
|
|
400
|
+
"count": limit,
|
|
401
|
+
"start": offset,
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
comments = []
|
|
406
|
+
for comment_data in response.data.get("elements", []):
|
|
407
|
+
comments.append(self._parse_comment(comment_data, post_id))
|
|
408
|
+
|
|
409
|
+
return comments
|
|
410
|
+
|
|
411
|
+
except httpx.HTTPError as e:
|
|
412
|
+
raise PlatformError(
|
|
413
|
+
f"Failed to fetch comments: {e}",
|
|
414
|
+
platform=self.platform_name,
|
|
415
|
+
) from e
|
|
416
|
+
|
|
417
|
+
async def create_comment(self, post_id: str, content: str) -> Comment:
|
|
418
|
+
"""Add a comment to a LinkedIn post.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
post_id: LinkedIn post URN.
|
|
422
|
+
content: Text content of the comment.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
Created Comment object.
|
|
426
|
+
|
|
427
|
+
Raises:
|
|
428
|
+
ValidationError: If comment content is invalid.
|
|
429
|
+
"""
|
|
430
|
+
if not self.api_client:
|
|
431
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
432
|
+
|
|
433
|
+
if not content or len(content) == 0:
|
|
434
|
+
raise ValidationError(
|
|
435
|
+
"Comment content cannot be empty",
|
|
436
|
+
platform=self.platform_name,
|
|
437
|
+
field="content",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# LinkedIn comment length limit
|
|
441
|
+
if len(content) > 1250:
|
|
442
|
+
raise ValidationError(
|
|
443
|
+
f"Comment exceeds 1250 characters ({len(content)} characters)",
|
|
444
|
+
platform=self.platform_name,
|
|
445
|
+
field="content",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
comment_payload = {
|
|
450
|
+
"actor": self.author_urn,
|
|
451
|
+
"message": {"text": content},
|
|
452
|
+
"object": post_id,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
response = await self.api_client.post(
|
|
456
|
+
f"/socialActions/{post_id}/comments",
|
|
457
|
+
data=comment_payload,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
comment_id = response.data["id"]
|
|
461
|
+
|
|
462
|
+
# Fetch full comment details
|
|
463
|
+
comment_response = await self.api_client.get(
|
|
464
|
+
f"/socialActions/{post_id}/comments/{comment_id}"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return self._parse_comment(comment_response.data, post_id)
|
|
468
|
+
|
|
469
|
+
except httpx.HTTPError as e:
|
|
470
|
+
raise PlatformError(
|
|
471
|
+
f"Failed to create comment: {e}",
|
|
472
|
+
platform=self.platform_name,
|
|
473
|
+
) from e
|
|
474
|
+
|
|
475
|
+
async def delete_comment(self, comment_id: str) -> bool:
|
|
476
|
+
"""Delete a LinkedIn comment.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
comment_id: LinkedIn comment URN.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
True if deletion was successful.
|
|
483
|
+
"""
|
|
484
|
+
if not self.api_client:
|
|
485
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
if not self.api_client._client:
|
|
489
|
+
raise RuntimeError("API client not initialized")
|
|
490
|
+
|
|
491
|
+
# Extract post ID from comment URN if needed
|
|
492
|
+
# Note: This is simplified; actual implementation may vary
|
|
493
|
+
await self.api_client._client.delete(
|
|
494
|
+
f"{self.base_url}/comments/{comment_id}"
|
|
495
|
+
)
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
except httpx.HTTPError as e:
|
|
499
|
+
raise PlatformError(
|
|
500
|
+
f"Failed to delete comment: {e}",
|
|
501
|
+
platform=self.platform_name,
|
|
502
|
+
) from e
|
|
503
|
+
|
|
504
|
+
# ==================== Media Methods ====================
|
|
505
|
+
|
|
506
|
+
async def upload_media(
|
|
507
|
+
self,
|
|
508
|
+
media_url: str,
|
|
509
|
+
media_type: str,
|
|
510
|
+
alt_text: str | None = None,
|
|
511
|
+
) -> MediaAttachment:
|
|
512
|
+
"""Upload media to LinkedIn.
|
|
513
|
+
|
|
514
|
+
Automatically handles images, videos, and documents with progress tracking.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
media_url: URL or file path of the media.
|
|
518
|
+
media_type: Type of media ("image", "video", or "document").
|
|
519
|
+
alt_text: Alternative text for accessibility.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
MediaAttachment object with LinkedIn media URN.
|
|
523
|
+
|
|
524
|
+
Raises:
|
|
525
|
+
MediaUploadError: If upload fails.
|
|
526
|
+
RuntimeError: If client not used as context manager.
|
|
527
|
+
|
|
528
|
+
Example:
|
|
529
|
+
>>> async with LinkedInClient(credentials) as client:
|
|
530
|
+
... media = await client.upload_media(
|
|
531
|
+
... "/path/to/image.jpg",
|
|
532
|
+
... "image",
|
|
533
|
+
... alt_text="Company logo"
|
|
534
|
+
... )
|
|
535
|
+
"""
|
|
536
|
+
if not self._media_manager:
|
|
537
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
# Convert URL to string if needed
|
|
541
|
+
file_path = str(media_url)
|
|
542
|
+
|
|
543
|
+
# Upload based on type
|
|
544
|
+
if media_type.lower() == "image":
|
|
545
|
+
asset = await self._media_manager.upload_image(
|
|
546
|
+
file_path, alt_text=alt_text
|
|
547
|
+
)
|
|
548
|
+
elif media_type.lower() == "video":
|
|
549
|
+
asset = await self._media_manager.upload_video(
|
|
550
|
+
file_path, wait_for_processing=True
|
|
551
|
+
)
|
|
552
|
+
elif media_type.lower() == "document":
|
|
553
|
+
asset = await self._media_manager.upload_document(file_path)
|
|
554
|
+
else:
|
|
555
|
+
raise ValidationError(
|
|
556
|
+
f"Unsupported media type: {media_type}. "
|
|
557
|
+
"Must be 'image', 'video', or 'document'",
|
|
558
|
+
platform=self.platform_name,
|
|
559
|
+
field="media_type",
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
return MediaAttachment(
|
|
563
|
+
media_id=asset.asset_id,
|
|
564
|
+
media_type=(
|
|
565
|
+
MediaType.IMAGE
|
|
566
|
+
if media_type.lower() == "image"
|
|
567
|
+
else (
|
|
568
|
+
MediaType.VIDEO
|
|
569
|
+
if media_type.lower() == "video"
|
|
570
|
+
else MediaType.IMAGE
|
|
571
|
+
) # Document
|
|
572
|
+
),
|
|
573
|
+
url=cast(HttpUrl, media_url),
|
|
574
|
+
alt_text=alt_text,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
except Exception as e:
|
|
578
|
+
raise MediaUploadError(
|
|
579
|
+
f"Failed to upload media: {e}",
|
|
580
|
+
platform=self.platform_name,
|
|
581
|
+
media_type=media_type,
|
|
582
|
+
) from e
|
|
583
|
+
|
|
584
|
+
async def upload_image(
|
|
585
|
+
self,
|
|
586
|
+
file_path: str,
|
|
587
|
+
*,
|
|
588
|
+
alt_text: str | None = None,
|
|
589
|
+
) -> MediaAsset:
|
|
590
|
+
"""Upload an image to LinkedIn.
|
|
591
|
+
|
|
592
|
+
Convenience method for image uploads.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
file_path: Path to image file or URL.
|
|
596
|
+
alt_text: Alternative text for accessibility.
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
MediaAsset with asset ID.
|
|
600
|
+
|
|
601
|
+
Example:
|
|
602
|
+
>>> async with LinkedInClient(credentials) as client:
|
|
603
|
+
... asset = await client.upload_image("photo.jpg")
|
|
604
|
+
"""
|
|
605
|
+
if not self._media_manager:
|
|
606
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
607
|
+
|
|
608
|
+
return await self._media_manager.upload_image(file_path, alt_text=alt_text)
|
|
609
|
+
|
|
610
|
+
async def upload_video(
|
|
611
|
+
self,
|
|
612
|
+
file_path: str,
|
|
613
|
+
*,
|
|
614
|
+
wait_for_processing: bool = True,
|
|
615
|
+
) -> MediaAsset:
|
|
616
|
+
"""Upload a video to LinkedIn.
|
|
617
|
+
|
|
618
|
+
Convenience method for video uploads.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
file_path: Path to video file or URL.
|
|
622
|
+
wait_for_processing: Wait for video processing to complete.
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
MediaAsset with asset ID.
|
|
626
|
+
|
|
627
|
+
Example:
|
|
628
|
+
>>> async with LinkedInClient(credentials) as client:
|
|
629
|
+
... asset = await client.upload_video("video.mp4")
|
|
630
|
+
"""
|
|
631
|
+
if not self._media_manager:
|
|
632
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
633
|
+
|
|
634
|
+
return await self._media_manager.upload_video(
|
|
635
|
+
file_path, wait_for_processing=wait_for_processing
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
async def upload_document(
|
|
639
|
+
self,
|
|
640
|
+
file_path: str,
|
|
641
|
+
*,
|
|
642
|
+
title: str | None = None,
|
|
643
|
+
) -> MediaAsset:
|
|
644
|
+
"""Upload a document/PDF to LinkedIn.
|
|
645
|
+
|
|
646
|
+
Convenience method for document uploads.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
file_path: Path to PDF file or URL.
|
|
650
|
+
title: Document title.
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
MediaAsset with asset ID.
|
|
654
|
+
|
|
655
|
+
Example:
|
|
656
|
+
>>> async with LinkedInClient(credentials) as client:
|
|
657
|
+
... asset = await client.upload_document("report.pdf")
|
|
658
|
+
"""
|
|
659
|
+
if not self._media_manager:
|
|
660
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
661
|
+
|
|
662
|
+
return await self._media_manager.upload_document(file_path, title=title)
|
|
663
|
+
|
|
664
|
+
# ==================== Helper Methods ====================
|
|
665
|
+
|
|
666
|
+
def _parse_post(self, data: dict[str, Any]) -> Post:
|
|
667
|
+
"""Parse LinkedIn API response into Post model.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
data: Raw API response data.
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
Post object.
|
|
674
|
+
"""
|
|
675
|
+
content = ""
|
|
676
|
+
if "specificContent" in data:
|
|
677
|
+
share_content = data["specificContent"].get(
|
|
678
|
+
"com.linkedin.ugc.ShareContent", {}
|
|
679
|
+
)
|
|
680
|
+
commentary = share_content.get("shareCommentary", {})
|
|
681
|
+
content = commentary.get("text", "")
|
|
682
|
+
|
|
683
|
+
# Extract timestamps
|
|
684
|
+
created_timestamp = data.get("created", {}).get("time", 0)
|
|
685
|
+
created_at = (
|
|
686
|
+
datetime.fromtimestamp(created_timestamp / 1000)
|
|
687
|
+
if created_timestamp
|
|
688
|
+
else datetime.now()
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
return Post(
|
|
692
|
+
post_id=data["id"],
|
|
693
|
+
platform=self.platform_name,
|
|
694
|
+
content=content,
|
|
695
|
+
media=[], # Media parsing would go here
|
|
696
|
+
status=(
|
|
697
|
+
PostStatus.PUBLISHED
|
|
698
|
+
if data.get("lifecycleState") == "PUBLISHED"
|
|
699
|
+
else PostStatus.DRAFT
|
|
700
|
+
),
|
|
701
|
+
created_at=created_at,
|
|
702
|
+
author_id=data.get("author"),
|
|
703
|
+
raw_data=data,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def _parse_comment(self, data: dict[str, Any], post_id: str) -> Comment:
|
|
707
|
+
"""Parse LinkedIn API response into Comment model.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
data: Raw API response data.
|
|
711
|
+
post_id: ID of the post this comment belongs to.
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
Comment object.
|
|
715
|
+
"""
|
|
716
|
+
content = data.get("message", {}).get("text", "")
|
|
717
|
+
created_timestamp = data.get("created", {}).get("time", 0)
|
|
718
|
+
created_at = (
|
|
719
|
+
datetime.fromtimestamp(created_timestamp / 1000)
|
|
720
|
+
if created_timestamp
|
|
721
|
+
else datetime.now()
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
return Comment(
|
|
725
|
+
comment_id=data["id"],
|
|
726
|
+
post_id=post_id,
|
|
727
|
+
platform=self.platform_name,
|
|
728
|
+
content=content,
|
|
729
|
+
author_id=data.get("actor", "unknown"),
|
|
730
|
+
created_at=created_at,
|
|
731
|
+
status=CommentStatus.VISIBLE,
|
|
732
|
+
raw_data=data,
|
|
733
|
+
)
|