marqetive-lib 0.1.6__py3-none-any.whl → 0.1.8__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 +13 -7
- marqetive/core/__init__.py +6 -4
- marqetive/core/base.py +92 -13
- marqetive/core/client.py +15 -0
- marqetive/core/models.py +111 -7
- marqetive/platforms/instagram/__init__.py +2 -1
- marqetive/platforms/instagram/client.py +29 -8
- marqetive/platforms/instagram/media.py +79 -13
- marqetive/platforms/instagram/models.py +74 -0
- marqetive/platforms/linkedin/__init__.py +51 -2
- marqetive/platforms/linkedin/client.py +978 -94
- marqetive/platforms/linkedin/media.py +156 -47
- marqetive/platforms/linkedin/models.py +413 -0
- marqetive/platforms/tiktok/__init__.py +2 -1
- marqetive/platforms/tiktok/client.py +5 -4
- marqetive/platforms/tiktok/media.py +193 -102
- marqetive/platforms/tiktok/models.py +79 -0
- marqetive/platforms/twitter/__init__.py +2 -1
- marqetive/platforms/twitter/client.py +86 -0
- marqetive/platforms/twitter/media.py +139 -70
- marqetive/platforms/twitter/models.py +58 -0
- marqetive/utils/media.py +86 -0
- marqetive/utils/oauth.py +31 -4
- {marqetive_lib-0.1.6.dist-info → marqetive_lib-0.1.8.dist-info}/METADATA +1 -9
- marqetive_lib-0.1.8.dist-info/RECORD +39 -0
- marqetive_lib-0.1.6.dist-info/RECORD +0 -35
- {marqetive_lib-0.1.6.dist-info → marqetive_lib-0.1.8.dist-info}/WHEEL +0 -0
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
"""LinkedIn API client implementation.
|
|
1
|
+
"""LinkedIn API client implementation using the Community Management API.
|
|
2
2
|
|
|
3
3
|
This module provides a concrete implementation of the SocialMediaPlatform
|
|
4
|
-
ABC for LinkedIn, using the LinkedIn
|
|
4
|
+
ABC for LinkedIn, using the LinkedIn Community Management API (REST endpoints).
|
|
5
|
+
Supports organization page management, posts, comments, reactions, and social metadata.
|
|
5
6
|
|
|
6
|
-
API Documentation: https://learn.microsoft.com/en-us/linkedin/
|
|
7
|
+
API Documentation: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/
|
|
7
8
|
"""
|
|
8
9
|
|
|
10
|
+
import contextlib
|
|
9
11
|
from datetime import datetime
|
|
10
12
|
from typing import Any, cast
|
|
13
|
+
from urllib.parse import quote
|
|
11
14
|
|
|
12
15
|
import httpx
|
|
13
16
|
from pydantic import HttpUrl
|
|
@@ -32,14 +35,28 @@ from marqetive.core.models import (
|
|
|
32
35
|
PostUpdateRequest,
|
|
33
36
|
)
|
|
34
37
|
from marqetive.platforms.linkedin.media import LinkedInMediaManager, MediaAsset
|
|
38
|
+
from marqetive.platforms.linkedin.models import (
|
|
39
|
+
CommentsState,
|
|
40
|
+
Organization,
|
|
41
|
+
OrganizationType,
|
|
42
|
+
PostSortBy,
|
|
43
|
+
Reaction,
|
|
44
|
+
ReactionType,
|
|
45
|
+
SocialMetadata,
|
|
46
|
+
)
|
|
35
47
|
|
|
36
48
|
|
|
37
49
|
class LinkedInClient(SocialMediaPlatform):
|
|
38
|
-
"""LinkedIn API client.
|
|
50
|
+
"""LinkedIn API client using the Community Management API.
|
|
39
51
|
|
|
40
52
|
This client implements the SocialMediaPlatform interface for LinkedIn,
|
|
41
|
-
using the LinkedIn
|
|
42
|
-
|
|
53
|
+
using the LinkedIn Community Management API (REST endpoints). It supports:
|
|
54
|
+
- Creating and managing posts (including updates)
|
|
55
|
+
- Comments with nested replies and mentions
|
|
56
|
+
- Reactions (Like, Celebrate, Love, Insightful, Support, Funny)
|
|
57
|
+
- Social metadata and engagement metrics
|
|
58
|
+
- Organization (Company Page) management
|
|
59
|
+
- Media uploads (images, videos, documents)
|
|
43
60
|
|
|
44
61
|
Note:
|
|
45
62
|
- Requires LinkedIn Developer app with appropriate permissions
|
|
@@ -47,40 +64,59 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
47
64
|
- Supports both personal profiles and organization pages
|
|
48
65
|
- Rate limits vary by API endpoint
|
|
49
66
|
|
|
67
|
+
Required Permissions:
|
|
68
|
+
- w_organization_social: Post/comment/react on organization pages
|
|
69
|
+
- r_organization_social: Read organization posts/comments
|
|
70
|
+
- rw_organization_admin: Organization management (optional)
|
|
71
|
+
- w_member_social: Post on personal profile
|
|
72
|
+
|
|
50
73
|
Example:
|
|
51
74
|
>>> credentials = AuthCredentials(
|
|
52
75
|
... platform="linkedin",
|
|
53
76
|
... access_token="your_access_token",
|
|
54
|
-
... user_id="urn:li:person:abc123
|
|
77
|
+
... user_id="urn:li:organization:12345" # or urn:li:person:abc123
|
|
55
78
|
... )
|
|
56
79
|
>>> async with LinkedInClient(credentials) as client:
|
|
80
|
+
... # Create a post
|
|
57
81
|
... request = PostCreateRequest(
|
|
58
82
|
... content="Excited to share our latest update!",
|
|
59
83
|
... link="https://example.com"
|
|
60
84
|
... )
|
|
61
85
|
... post = await client.create_post(request)
|
|
86
|
+
...
|
|
87
|
+
... # Add a reaction
|
|
88
|
+
... await client.add_reaction(post.post_id, ReactionType.LIKE)
|
|
89
|
+
...
|
|
90
|
+
... # Get engagement metrics
|
|
91
|
+
... metadata = await client.get_social_metadata(post.post_id)
|
|
62
92
|
"""
|
|
63
93
|
|
|
94
|
+
# Default API version in YYYYMM format
|
|
95
|
+
DEFAULT_API_VERSION = "202511"
|
|
96
|
+
|
|
64
97
|
def __init__(
|
|
65
98
|
self,
|
|
66
99
|
credentials: AuthCredentials,
|
|
67
100
|
timeout: float = 30.0,
|
|
68
|
-
api_version: str =
|
|
101
|
+
api_version: str | None = None,
|
|
69
102
|
progress_callback: ProgressCallback | None = None,
|
|
70
103
|
) -> None:
|
|
71
104
|
"""Initialize LinkedIn client.
|
|
72
105
|
|
|
73
106
|
Args:
|
|
74
|
-
credentials: LinkedIn authentication credentials
|
|
75
|
-
|
|
76
|
-
|
|
107
|
+
credentials: LinkedIn authentication credentials. Must include
|
|
108
|
+
user_id as URN (urn:li:person:xxx or urn:li:organization:xxx).
|
|
109
|
+
timeout: Request timeout in seconds.
|
|
110
|
+
api_version: LinkedIn API version in YYYYMM format (e.g., "202511").
|
|
111
|
+
Defaults to the latest supported version.
|
|
77
112
|
progress_callback: Optional callback for progress updates during
|
|
78
113
|
long-running operations like media uploads.
|
|
79
114
|
|
|
80
115
|
Raises:
|
|
81
|
-
PlatformAuthError: If credentials are invalid
|
|
116
|
+
PlatformAuthError: If credentials are invalid.
|
|
82
117
|
"""
|
|
83
|
-
|
|
118
|
+
# Use REST API base URL for Community Management API
|
|
119
|
+
base_url = "https://api.linkedin.com/rest"
|
|
84
120
|
super().__init__(
|
|
85
121
|
platform_name="linkedin",
|
|
86
122
|
credentials=credentials,
|
|
@@ -91,11 +127,24 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
91
127
|
self.author_urn = (
|
|
92
128
|
credentials.user_id
|
|
93
129
|
) # urn:li:person:xxx or urn:li:organization:xxx
|
|
94
|
-
self.
|
|
130
|
+
self.linkedin_version = api_version or self.DEFAULT_API_VERSION
|
|
95
131
|
|
|
96
132
|
# Media manager (initialized in __aenter__)
|
|
97
133
|
self._media_manager: LinkedInMediaManager | None = None
|
|
98
134
|
|
|
135
|
+
def _build_auth_headers(self) -> dict[str, str]:
|
|
136
|
+
"""Build authentication headers for LinkedIn REST API.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Dictionary of headers including LinkedIn-specific version headers.
|
|
140
|
+
"""
|
|
141
|
+
return {
|
|
142
|
+
"Authorization": f"Bearer {self.credentials.access_token}",
|
|
143
|
+
"Linkedin-Version": self.linkedin_version,
|
|
144
|
+
"X-Restli-Protocol-Version": "2.0.0",
|
|
145
|
+
"Content-Type": "application/json",
|
|
146
|
+
}
|
|
147
|
+
|
|
99
148
|
async def __aenter__(self) -> "LinkedInClient":
|
|
100
149
|
"""Async context manager entry."""
|
|
101
150
|
await super().__aenter__()
|
|
@@ -110,7 +159,7 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
110
159
|
self._media_manager = LinkedInMediaManager(
|
|
111
160
|
person_urn=self.author_urn,
|
|
112
161
|
access_token=self.credentials.access_token,
|
|
113
|
-
|
|
162
|
+
linkedin_version=self.linkedin_version,
|
|
114
163
|
timeout=self.timeout,
|
|
115
164
|
)
|
|
116
165
|
|
|
@@ -184,18 +233,29 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
184
233
|
|
|
185
234
|
try:
|
|
186
235
|
# Verify credentials by fetching user profile
|
|
187
|
-
|
|
188
|
-
|
|
236
|
+
# Note: /userinfo is the standard endpoint for the REST API
|
|
237
|
+
if not self.api_client._client:
|
|
238
|
+
raise RuntimeError("API client not initialized")
|
|
239
|
+
|
|
240
|
+
response = await self.api_client._client.get(
|
|
241
|
+
"https://api.linkedin.com/v2/userinfo",
|
|
242
|
+
headers=self._build_auth_headers(),
|
|
243
|
+
)
|
|
244
|
+
return response.status_code == 200
|
|
189
245
|
except httpx.HTTPError:
|
|
190
246
|
return False
|
|
191
247
|
|
|
192
248
|
# ==================== Post CRUD Methods ====================
|
|
193
249
|
|
|
194
250
|
async def create_post(self, request: PostCreateRequest) -> Post:
|
|
195
|
-
"""Create and publish a LinkedIn post
|
|
251
|
+
"""Create and publish a LinkedIn post.
|
|
252
|
+
|
|
253
|
+
Uses the Community Management API to create posts on personal profiles
|
|
254
|
+
or organization pages.
|
|
196
255
|
|
|
197
256
|
Args:
|
|
198
|
-
request: Post creation request.
|
|
257
|
+
request: Post creation request. Use additional_data for LinkedIn-specific
|
|
258
|
+
options like visibility, distribution, and call-to-action.
|
|
199
259
|
|
|
200
260
|
Returns:
|
|
201
261
|
Created Post object.
|
|
@@ -203,6 +263,17 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
203
263
|
Raises:
|
|
204
264
|
ValidationError: If request is invalid.
|
|
205
265
|
MediaUploadError: If media upload fails.
|
|
266
|
+
|
|
267
|
+
Example:
|
|
268
|
+
>>> request = PostCreateRequest(
|
|
269
|
+
... content="Check out our new product!",
|
|
270
|
+
... link="https://example.com/product",
|
|
271
|
+
... additional_data={
|
|
272
|
+
... "visibility": "PUBLIC",
|
|
273
|
+
... "call_to_action": "LEARN_MORE"
|
|
274
|
+
... }
|
|
275
|
+
... )
|
|
276
|
+
>>> post = await client.create_post(request)
|
|
206
277
|
"""
|
|
207
278
|
if not self.api_client:
|
|
208
279
|
raise RuntimeError("Client must be used as async context manager")
|
|
@@ -223,49 +294,81 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
223
294
|
)
|
|
224
295
|
|
|
225
296
|
try:
|
|
226
|
-
# Build
|
|
227
|
-
|
|
297
|
+
# Build REST API payload structure
|
|
298
|
+
post_payload: dict[str, Any] = {
|
|
228
299
|
"author": self.author_urn,
|
|
229
|
-
"
|
|
230
|
-
"
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
"
|
|
234
|
-
|
|
300
|
+
"commentary": request.content,
|
|
301
|
+
"visibility": request.additional_data.get("visibility", "PUBLIC"),
|
|
302
|
+
"distribution": {
|
|
303
|
+
"feedDistribution": request.additional_data.get(
|
|
304
|
+
"feed_distribution", "MAIN_FEED"
|
|
305
|
+
),
|
|
306
|
+
"targetEntities": request.additional_data.get(
|
|
307
|
+
"target_entities", []
|
|
308
|
+
),
|
|
309
|
+
"thirdPartyDistributionChannels": [],
|
|
235
310
|
},
|
|
236
|
-
"
|
|
311
|
+
"lifecycleState": "PUBLISHED",
|
|
312
|
+
"isReshareDisabledByAuthor": request.additional_data.get(
|
|
313
|
+
"disable_reshare", False
|
|
314
|
+
),
|
|
237
315
|
}
|
|
238
316
|
|
|
239
|
-
# Add media if provided
|
|
317
|
+
# Add media content if provided
|
|
240
318
|
if request.media_ids:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
] =
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
for media_id in request.media_ids
|
|
249
|
-
]
|
|
250
|
-
|
|
251
|
-
# Add link if provided
|
|
252
|
-
if request.link:
|
|
253
|
-
share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
|
|
254
|
-
"shareMediaCategory"
|
|
255
|
-
] = "ARTICLE"
|
|
256
|
-
share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
|
|
257
|
-
"media"
|
|
258
|
-
] = [
|
|
259
|
-
{
|
|
260
|
-
"status": "READY",
|
|
261
|
-
"originalUrl": request.link,
|
|
319
|
+
# Determine media type from URN prefix
|
|
320
|
+
media_id = request.media_ids[0]
|
|
321
|
+
post_payload["content"] = {
|
|
322
|
+
"media": {
|
|
323
|
+
"id": media_id,
|
|
324
|
+
"title": request.additional_data.get("media_title"),
|
|
325
|
+
"altText": request.additional_data.get("media_alt_text"),
|
|
262
326
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
327
|
+
}
|
|
328
|
+
# Remove None values
|
|
329
|
+
post_payload["content"]["media"] = {
|
|
330
|
+
k: v
|
|
331
|
+
for k, v in post_payload["content"]["media"].items()
|
|
332
|
+
if v is not None
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
# Add article/link if provided (and no media)
|
|
336
|
+
elif request.link:
|
|
337
|
+
post_payload["content"] = {
|
|
338
|
+
"article": {
|
|
339
|
+
"source": request.link,
|
|
340
|
+
"title": request.additional_data.get("article_title"),
|
|
341
|
+
"description": request.additional_data.get(
|
|
342
|
+
"article_description"
|
|
343
|
+
),
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
# Add thumbnail if provided
|
|
347
|
+
if thumbnail := request.additional_data.get("article_thumbnail"):
|
|
348
|
+
post_payload["content"]["article"]["thumbnail"] = thumbnail
|
|
349
|
+
# Remove None values
|
|
350
|
+
post_payload["content"]["article"] = {
|
|
351
|
+
k: v
|
|
352
|
+
for k, v in post_payload["content"]["article"].items()
|
|
353
|
+
if v is not None
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# Add call-to-action if provided
|
|
357
|
+
if cta := request.additional_data.get("call_to_action"):
|
|
358
|
+
post_payload["contentCallToActionLabel"] = cta
|
|
359
|
+
if landing_page := request.additional_data.get("landing_page"):
|
|
360
|
+
post_payload["contentLandingPage"] = landing_page
|
|
361
|
+
|
|
362
|
+
# Create the post
|
|
363
|
+
response = await self.api_client.post("/posts", data=post_payload)
|
|
364
|
+
|
|
365
|
+
# Post ID is returned in x-restli-id header or response body
|
|
366
|
+
post_id = response.data.get("id") or response.headers.get("x-restli-id")
|
|
367
|
+
if not post_id:
|
|
368
|
+
raise PlatformError(
|
|
369
|
+
"Failed to get post ID from response",
|
|
370
|
+
platform=self.platform_name,
|
|
371
|
+
)
|
|
269
372
|
|
|
270
373
|
# Fetch full post details
|
|
271
374
|
return await self.get_post(post_id)
|
|
@@ -280,7 +383,7 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
280
383
|
"""Retrieve a LinkedIn post by ID.
|
|
281
384
|
|
|
282
385
|
Args:
|
|
283
|
-
post_id: LinkedIn post URN (e.g., urn:li:share:123).
|
|
386
|
+
post_id: LinkedIn post URN (e.g., urn:li:share:123 or urn:li:ugcPost:123).
|
|
284
387
|
|
|
285
388
|
Returns:
|
|
286
389
|
Post object with current data.
|
|
@@ -292,7 +395,12 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
292
395
|
raise RuntimeError("Client must be used as async context manager")
|
|
293
396
|
|
|
294
397
|
try:
|
|
295
|
-
|
|
398
|
+
# URL-encode the post URN
|
|
399
|
+
encoded_post_id = quote(post_id, safe="")
|
|
400
|
+
response = await self.api_client.get(
|
|
401
|
+
f"/posts/{encoded_post_id}",
|
|
402
|
+
params={"viewContext": "AUTHOR"},
|
|
403
|
+
)
|
|
296
404
|
data = response.data
|
|
297
405
|
return self._parse_post(data)
|
|
298
406
|
|
|
@@ -315,25 +423,95 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
315
423
|
|
|
316
424
|
async def update_post(
|
|
317
425
|
self,
|
|
318
|
-
post_id: str,
|
|
319
|
-
request: PostUpdateRequest,
|
|
426
|
+
post_id: str,
|
|
427
|
+
request: PostUpdateRequest,
|
|
320
428
|
) -> Post:
|
|
321
429
|
"""Update a LinkedIn post.
|
|
322
430
|
|
|
323
|
-
|
|
324
|
-
|
|
431
|
+
The Community Management API supports updating certain fields of published posts:
|
|
432
|
+
- commentary (post text)
|
|
433
|
+
- contentCallToActionLabel
|
|
434
|
+
- contentLandingPage
|
|
435
|
+
- lifecycleState
|
|
436
|
+
- adContext.dscName, adContext.dscStatus (for sponsored content)
|
|
325
437
|
|
|
326
438
|
Args:
|
|
327
439
|
post_id: LinkedIn post URN.
|
|
328
|
-
request: Post update request.
|
|
440
|
+
request: Post update request. Use additional_data for LinkedIn-specific
|
|
441
|
+
fields like call_to_action and landing_page.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Updated Post object.
|
|
329
445
|
|
|
330
446
|
Raises:
|
|
331
|
-
|
|
447
|
+
PostNotFoundError: If post doesn't exist.
|
|
448
|
+
ValidationError: If update data is invalid.
|
|
449
|
+
|
|
450
|
+
Example:
|
|
451
|
+
>>> request = PostUpdateRequest(
|
|
452
|
+
... content="Updated post content!",
|
|
453
|
+
... additional_data={"call_to_action": "LEARN_MORE"}
|
|
454
|
+
... )
|
|
455
|
+
>>> post = await client.update_post("urn:li:share:123", request)
|
|
332
456
|
"""
|
|
333
|
-
|
|
334
|
-
"
|
|
335
|
-
|
|
336
|
-
|
|
457
|
+
if not self.api_client:
|
|
458
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
459
|
+
|
|
460
|
+
if not self.api_client._client:
|
|
461
|
+
raise RuntimeError("API client not initialized")
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
# Build PARTIAL_UPDATE payload
|
|
465
|
+
patch_payload: dict[str, Any] = {"patch": {"$set": {}}}
|
|
466
|
+
|
|
467
|
+
if request.content is not None:
|
|
468
|
+
patch_payload["patch"]["$set"]["commentary"] = request.content
|
|
469
|
+
|
|
470
|
+
# Handle additional LinkedIn-specific fields
|
|
471
|
+
additional = getattr(request, "additional_data", {}) or {}
|
|
472
|
+
if cta := additional.get("call_to_action"):
|
|
473
|
+
patch_payload["patch"]["$set"]["contentCallToActionLabel"] = cta
|
|
474
|
+
if landing_page := additional.get("landing_page"):
|
|
475
|
+
patch_payload["patch"]["$set"]["contentLandingPage"] = landing_page
|
|
476
|
+
if lifecycle := additional.get("lifecycle_state"):
|
|
477
|
+
patch_payload["patch"]["$set"]["lifecycleState"] = lifecycle
|
|
478
|
+
|
|
479
|
+
# Handle ad context updates if provided
|
|
480
|
+
if ad_context := additional.get("ad_context"):
|
|
481
|
+
patch_payload["patch"]["adContext"] = {"$set": ad_context}
|
|
482
|
+
|
|
483
|
+
# Make the PARTIAL_UPDATE request
|
|
484
|
+
encoded_post_id = quote(post_id, safe="")
|
|
485
|
+
headers = {
|
|
486
|
+
**self._build_auth_headers(),
|
|
487
|
+
"X-RestLi-Method": "PARTIAL_UPDATE",
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
await self.api_client._client.post(
|
|
491
|
+
f"{self.base_url}/posts/{encoded_post_id}",
|
|
492
|
+
json=patch_payload,
|
|
493
|
+
headers=headers,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Fetch and return the updated post
|
|
497
|
+
return await self.get_post(post_id)
|
|
498
|
+
|
|
499
|
+
except httpx.HTTPStatusError as e:
|
|
500
|
+
if e.response.status_code == 404:
|
|
501
|
+
raise PostNotFoundError(
|
|
502
|
+
post_id=post_id,
|
|
503
|
+
platform=self.platform_name,
|
|
504
|
+
status_code=404,
|
|
505
|
+
) from e
|
|
506
|
+
raise PlatformError(
|
|
507
|
+
f"Failed to update post: {e}",
|
|
508
|
+
platform=self.platform_name,
|
|
509
|
+
) from e
|
|
510
|
+
except httpx.HTTPError as e:
|
|
511
|
+
raise PlatformError(
|
|
512
|
+
f"Failed to update post: {e}",
|
|
513
|
+
platform=self.platform_name,
|
|
514
|
+
) from e
|
|
337
515
|
|
|
338
516
|
async def delete_post(self, post_id: str) -> bool:
|
|
339
517
|
"""Delete a LinkedIn post.
|
|
@@ -351,11 +529,14 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
351
529
|
raise RuntimeError("Client must be used as async context manager")
|
|
352
530
|
|
|
353
531
|
try:
|
|
354
|
-
# LinkedIn uses DELETE method for removing posts
|
|
355
532
|
if not self.api_client._client:
|
|
356
533
|
raise RuntimeError("API client not initialized")
|
|
357
534
|
|
|
358
|
-
|
|
535
|
+
encoded_post_id = quote(post_id, safe="")
|
|
536
|
+
await self.api_client._client.delete(
|
|
537
|
+
f"{self.base_url}/posts/{encoded_post_id}",
|
|
538
|
+
headers=self._build_auth_headers(),
|
|
539
|
+
)
|
|
359
540
|
return True
|
|
360
541
|
|
|
361
542
|
except httpx.HTTPStatusError as e:
|
|
@@ -375,6 +556,79 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
375
556
|
platform=self.platform_name,
|
|
376
557
|
) from e
|
|
377
558
|
|
|
559
|
+
async def list_posts(
|
|
560
|
+
self,
|
|
561
|
+
author_urn: str | None = None,
|
|
562
|
+
limit: int = 10,
|
|
563
|
+
offset: int = 0,
|
|
564
|
+
sort_by: PostSortBy = "LAST_MODIFIED",
|
|
565
|
+
) -> list[Post]:
|
|
566
|
+
"""List posts by author.
|
|
567
|
+
|
|
568
|
+
Retrieves posts created by a specific person or organization.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
author_urn: Person or organization URN. Defaults to the client's author_urn.
|
|
572
|
+
limit: Maximum number of posts to retrieve (max 100).
|
|
573
|
+
offset: Number of posts to skip for pagination.
|
|
574
|
+
sort_by: Sort order - "LAST_MODIFIED" or "CREATED".
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
List of Post objects.
|
|
578
|
+
|
|
579
|
+
Example:
|
|
580
|
+
>>> posts = await client.list_posts(limit=20, sort_by="CREATED")
|
|
581
|
+
>>> org_posts = await client.list_posts(
|
|
582
|
+
... author_urn="urn:li:organization:12345",
|
|
583
|
+
... limit=50
|
|
584
|
+
... )
|
|
585
|
+
"""
|
|
586
|
+
if not self.api_client:
|
|
587
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
588
|
+
|
|
589
|
+
if not self.api_client._client:
|
|
590
|
+
raise RuntimeError("API client not initialized")
|
|
591
|
+
|
|
592
|
+
try:
|
|
593
|
+
author = author_urn or self.author_urn
|
|
594
|
+
if not author:
|
|
595
|
+
raise PlatformError(
|
|
596
|
+
"Author URN is required for listing posts",
|
|
597
|
+
platform=self.platform_name,
|
|
598
|
+
)
|
|
599
|
+
encoded_author = quote(author, safe="")
|
|
600
|
+
|
|
601
|
+
# Use FINDER method
|
|
602
|
+
headers = {
|
|
603
|
+
**self._build_auth_headers(),
|
|
604
|
+
"X-RestLi-Method": "FINDER",
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
response = await self.api_client._client.get(
|
|
608
|
+
f"{self.base_url}/posts",
|
|
609
|
+
params={
|
|
610
|
+
"author": encoded_author,
|
|
611
|
+
"q": "author",
|
|
612
|
+
"count": min(limit, 100),
|
|
613
|
+
"start": offset,
|
|
614
|
+
"sortBy": sort_by,
|
|
615
|
+
},
|
|
616
|
+
headers=headers,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
posts = []
|
|
620
|
+
data = response.json()
|
|
621
|
+
for post_data in data.get("elements", []):
|
|
622
|
+
posts.append(self._parse_post(post_data))
|
|
623
|
+
|
|
624
|
+
return posts
|
|
625
|
+
|
|
626
|
+
except httpx.HTTPError as e:
|
|
627
|
+
raise PlatformError(
|
|
628
|
+
f"Failed to list posts: {e}",
|
|
629
|
+
platform=self.platform_name,
|
|
630
|
+
) from e
|
|
631
|
+
|
|
378
632
|
# ==================== Comment Methods ====================
|
|
379
633
|
|
|
380
634
|
async def get_comments(
|
|
@@ -386,7 +640,7 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
386
640
|
"""Retrieve comments for a LinkedIn post.
|
|
387
641
|
|
|
388
642
|
Args:
|
|
389
|
-
post_id: LinkedIn post URN.
|
|
643
|
+
post_id: LinkedIn post URN (share or ugcPost).
|
|
390
644
|
limit: Maximum number of comments to retrieve.
|
|
391
645
|
offset: Number of comments to skip.
|
|
392
646
|
|
|
@@ -397,9 +651,11 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
397
651
|
raise RuntimeError("Client must be used as async context manager")
|
|
398
652
|
|
|
399
653
|
try:
|
|
400
|
-
#
|
|
654
|
+
# URL-encode the post URN
|
|
655
|
+
encoded_post_id = quote(post_id, safe="")
|
|
656
|
+
|
|
401
657
|
response = await self.api_client.get(
|
|
402
|
-
f"/socialActions/{
|
|
658
|
+
f"/socialActions/{encoded_post_id}/comments",
|
|
403
659
|
params={
|
|
404
660
|
"count": limit,
|
|
405
661
|
"start": offset,
|
|
@@ -418,18 +674,38 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
418
674
|
platform=self.platform_name,
|
|
419
675
|
) from e
|
|
420
676
|
|
|
421
|
-
async def create_comment(
|
|
677
|
+
async def create_comment(
|
|
678
|
+
self,
|
|
679
|
+
post_id: str,
|
|
680
|
+
content: str,
|
|
681
|
+
parent_comment_id: str | None = None,
|
|
682
|
+
image_id: str | None = None,
|
|
683
|
+
) -> Comment:
|
|
422
684
|
"""Add a comment to a LinkedIn post.
|
|
423
685
|
|
|
686
|
+
Supports nested comments (replies) by specifying a parent_comment_id.
|
|
687
|
+
|
|
424
688
|
Args:
|
|
425
|
-
post_id: LinkedIn post URN.
|
|
689
|
+
post_id: LinkedIn post URN (share or ugcPost).
|
|
426
690
|
content: Text content of the comment.
|
|
691
|
+
parent_comment_id: URN of parent comment for nested replies.
|
|
692
|
+
image_id: URN of an image to attach to the comment.
|
|
427
693
|
|
|
428
694
|
Returns:
|
|
429
695
|
Created Comment object.
|
|
430
696
|
|
|
431
697
|
Raises:
|
|
432
698
|
ValidationError: If comment content is invalid.
|
|
699
|
+
|
|
700
|
+
Example:
|
|
701
|
+
>>> # Top-level comment
|
|
702
|
+
>>> comment = await client.create_comment(post_id, "Great post!")
|
|
703
|
+
>>> # Reply to a comment
|
|
704
|
+
>>> reply = await client.create_comment(
|
|
705
|
+
... post_id,
|
|
706
|
+
... "Thanks!",
|
|
707
|
+
... parent_comment_id=comment.comment_id
|
|
708
|
+
... )
|
|
433
709
|
"""
|
|
434
710
|
if not self.api_client:
|
|
435
711
|
raise RuntimeError("Client must be used as async context manager")
|
|
@@ -450,22 +726,34 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
450
726
|
)
|
|
451
727
|
|
|
452
728
|
try:
|
|
453
|
-
|
|
729
|
+
encoded_post_id = quote(post_id, safe="")
|
|
730
|
+
|
|
731
|
+
comment_payload: dict[str, Any] = {
|
|
454
732
|
"actor": self.author_urn,
|
|
455
733
|
"message": {"text": content},
|
|
456
734
|
"object": post_id,
|
|
457
735
|
}
|
|
458
736
|
|
|
737
|
+
# Add parent comment for nested replies
|
|
738
|
+
if parent_comment_id:
|
|
739
|
+
comment_payload["parentComment"] = parent_comment_id
|
|
740
|
+
|
|
741
|
+
# Add image content if provided
|
|
742
|
+
if image_id:
|
|
743
|
+
comment_payload["content"] = [{"entity": {"image": image_id}}]
|
|
744
|
+
|
|
459
745
|
response = await self.api_client.post(
|
|
460
|
-
f"/socialActions/{
|
|
746
|
+
f"/socialActions/{encoded_post_id}/comments",
|
|
461
747
|
data=comment_payload,
|
|
462
748
|
)
|
|
463
749
|
|
|
464
|
-
|
|
750
|
+
# Get comment ID from response
|
|
751
|
+
comment_id = response.data.get("id") or response.headers.get("x-restli-id")
|
|
465
752
|
|
|
466
753
|
# Fetch full comment details
|
|
754
|
+
encoded_comment_id = quote(str(comment_id), safe="")
|
|
467
755
|
comment_response = await self.api_client.get(
|
|
468
|
-
f"/socialActions/{
|
|
756
|
+
f"/socialActions/{encoded_post_id}/comments/{encoded_comment_id}"
|
|
469
757
|
)
|
|
470
758
|
|
|
471
759
|
return self._parse_comment(comment_response.data, post_id)
|
|
@@ -476,11 +764,86 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
476
764
|
platform=self.platform_name,
|
|
477
765
|
) from e
|
|
478
766
|
|
|
479
|
-
async def
|
|
767
|
+
async def update_comment(
|
|
768
|
+
self,
|
|
769
|
+
post_id: str,
|
|
770
|
+
comment_id: str,
|
|
771
|
+
content: str,
|
|
772
|
+
) -> Comment:
|
|
773
|
+
"""Update a LinkedIn comment.
|
|
774
|
+
|
|
775
|
+
Only the text content can be updated. Attributes (mentions) can also be modified.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
post_id: LinkedIn post URN (share or ugcPost).
|
|
779
|
+
comment_id: Comment ID (not the full URN).
|
|
780
|
+
content: New text content for the comment.
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
Updated Comment object.
|
|
784
|
+
|
|
785
|
+
Raises:
|
|
786
|
+
ValidationError: If comment content is invalid.
|
|
787
|
+
"""
|
|
788
|
+
if not self.api_client:
|
|
789
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
790
|
+
|
|
791
|
+
if not self.api_client._client:
|
|
792
|
+
raise RuntimeError("API client not initialized")
|
|
793
|
+
|
|
794
|
+
if not content or len(content) == 0:
|
|
795
|
+
raise ValidationError(
|
|
796
|
+
"Comment content cannot be empty",
|
|
797
|
+
platform=self.platform_name,
|
|
798
|
+
field="content",
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
if len(content) > 1250:
|
|
802
|
+
raise ValidationError(
|
|
803
|
+
f"Comment exceeds 1250 characters ({len(content)} characters)",
|
|
804
|
+
platform=self.platform_name,
|
|
805
|
+
field="content",
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
try:
|
|
809
|
+
encoded_post_id = quote(post_id, safe="")
|
|
810
|
+
encoded_comment_id = quote(comment_id, safe="")
|
|
811
|
+
encoded_actor = quote(self._ensure_author_urn(), safe="")
|
|
812
|
+
|
|
813
|
+
# Build PARTIAL_UPDATE payload
|
|
814
|
+
patch_payload = {"patch": {"message": {"$set": {"text": content}}}}
|
|
815
|
+
|
|
816
|
+
headers = {
|
|
817
|
+
**self._build_auth_headers(),
|
|
818
|
+
"X-RestLi-Method": "PARTIAL_UPDATE",
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
await self.api_client._client.post(
|
|
822
|
+
f"{self.base_url}/socialActions/{encoded_post_id}/comments/{encoded_comment_id}",
|
|
823
|
+
params={"actor": encoded_actor},
|
|
824
|
+
json=patch_payload,
|
|
825
|
+
headers=headers,
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
# Fetch and return the updated comment
|
|
829
|
+
comment_response = await self.api_client.get(
|
|
830
|
+
f"/socialActions/{encoded_post_id}/comments/{encoded_comment_id}"
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
return self._parse_comment(comment_response.data, post_id)
|
|
834
|
+
|
|
835
|
+
except httpx.HTTPError as e:
|
|
836
|
+
raise PlatformError(
|
|
837
|
+
f"Failed to update comment: {e}",
|
|
838
|
+
platform=self.platform_name,
|
|
839
|
+
) from e
|
|
840
|
+
|
|
841
|
+
async def delete_comment(self, comment_id: str, post_id: str | None = None) -> bool:
|
|
480
842
|
"""Delete a LinkedIn comment.
|
|
481
843
|
|
|
482
844
|
Args:
|
|
483
|
-
comment_id: LinkedIn comment URN.
|
|
845
|
+
comment_id: LinkedIn comment ID or full URN.
|
|
846
|
+
post_id: LinkedIn post URN. Required if comment_id is not a full URN.
|
|
484
847
|
|
|
485
848
|
Returns:
|
|
486
849
|
True if deletion was successful.
|
|
@@ -492,10 +855,22 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
492
855
|
if not self.api_client._client:
|
|
493
856
|
raise RuntimeError("API client not initialized")
|
|
494
857
|
|
|
495
|
-
|
|
496
|
-
|
|
858
|
+
encoded_actor = quote(self._ensure_author_urn(), safe="")
|
|
859
|
+
|
|
860
|
+
# If post_id is provided, use the socialActions endpoint
|
|
861
|
+
if post_id:
|
|
862
|
+
encoded_post_id = quote(post_id, safe="")
|
|
863
|
+
encoded_comment_id = quote(comment_id, safe="")
|
|
864
|
+
url = f"{self.base_url}/socialActions/{encoded_post_id}/comments/{encoded_comment_id}"
|
|
865
|
+
else:
|
|
866
|
+
# Try to use the comment URN directly
|
|
867
|
+
encoded_comment_id = quote(comment_id, safe="")
|
|
868
|
+
url = f"{self.base_url}/comments/{encoded_comment_id}"
|
|
869
|
+
|
|
497
870
|
await self.api_client._client.delete(
|
|
498
|
-
|
|
871
|
+
url,
|
|
872
|
+
params={"actor": encoded_actor},
|
|
873
|
+
headers=self._build_auth_headers(),
|
|
499
874
|
)
|
|
500
875
|
return True
|
|
501
876
|
|
|
@@ -563,6 +938,17 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
563
938
|
field="media_type",
|
|
564
939
|
)
|
|
565
940
|
|
|
941
|
+
# Determine the URL for the media attachment
|
|
942
|
+
# Use download_url if available, original URL if it's http(s), or construct from asset ID
|
|
943
|
+
if asset.download_url:
|
|
944
|
+
media_url_final = asset.download_url
|
|
945
|
+
elif media_url.startswith(("http://", "https://")):
|
|
946
|
+
media_url_final = media_url
|
|
947
|
+
else:
|
|
948
|
+
# Construct a LinkedIn asset URL for local file uploads
|
|
949
|
+
# This uses the asset URN as part of the URL
|
|
950
|
+
media_url_final = f"https://media.linkedin.com/asset/{asset.asset_id}"
|
|
951
|
+
|
|
566
952
|
return MediaAttachment(
|
|
567
953
|
media_id=asset.asset_id,
|
|
568
954
|
media_type=(
|
|
@@ -574,10 +960,13 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
574
960
|
else MediaType.IMAGE
|
|
575
961
|
) # Document
|
|
576
962
|
),
|
|
577
|
-
url=cast(HttpUrl,
|
|
963
|
+
url=cast(HttpUrl, media_url_final),
|
|
578
964
|
alt_text=alt_text,
|
|
579
965
|
)
|
|
580
966
|
|
|
967
|
+
except ValidationError:
|
|
968
|
+
# Let validation errors propagate as-is
|
|
969
|
+
raise
|
|
581
970
|
except Exception as e:
|
|
582
971
|
raise MediaUploadError(
|
|
583
972
|
f"Failed to upload media: {e}",
|
|
@@ -665,44 +1054,539 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
665
1054
|
|
|
666
1055
|
return await self._media_manager.upload_document(file_path, title=title)
|
|
667
1056
|
|
|
1057
|
+
# ==================== Reactions API Methods ====================
|
|
1058
|
+
|
|
1059
|
+
async def get_reactions(self, entity_urn: str, limit: int = 50) -> list[Reaction]:
|
|
1060
|
+
"""Get reactions on a post or comment.
|
|
1061
|
+
|
|
1062
|
+
Args:
|
|
1063
|
+
entity_urn: URN of the entity (share, ugcPost, or comment).
|
|
1064
|
+
limit: Maximum number of reactions to retrieve.
|
|
1065
|
+
|
|
1066
|
+
Returns:
|
|
1067
|
+
List of Reaction objects.
|
|
1068
|
+
|
|
1069
|
+
Example:
|
|
1070
|
+
>>> reactions = await client.get_reactions("urn:li:share:12345")
|
|
1071
|
+
"""
|
|
1072
|
+
if not self.api_client:
|
|
1073
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
1074
|
+
|
|
1075
|
+
if not self.api_client._client:
|
|
1076
|
+
raise RuntimeError("API client not initialized")
|
|
1077
|
+
|
|
1078
|
+
try:
|
|
1079
|
+
encoded_entity = quote(entity_urn, safe="")
|
|
1080
|
+
|
|
1081
|
+
response = await self.api_client._client.get(
|
|
1082
|
+
f"{self.base_url}/reactions/(entity:{encoded_entity})",
|
|
1083
|
+
params={
|
|
1084
|
+
"q": "entity",
|
|
1085
|
+
"count": limit,
|
|
1086
|
+
},
|
|
1087
|
+
headers=self._build_auth_headers(),
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
reactions = []
|
|
1091
|
+
data = response.json()
|
|
1092
|
+
for reaction_data in data.get("elements", []):
|
|
1093
|
+
reactions.append(
|
|
1094
|
+
Reaction(
|
|
1095
|
+
actor=reaction_data.get("actor", ""),
|
|
1096
|
+
entity=entity_urn,
|
|
1097
|
+
reaction_type=ReactionType(
|
|
1098
|
+
reaction_data.get("reactionType", "LIKE")
|
|
1099
|
+
),
|
|
1100
|
+
created_at=(
|
|
1101
|
+
datetime.fromtimestamp(reaction_data["created"] / 1000)
|
|
1102
|
+
if "created" in reaction_data
|
|
1103
|
+
else None
|
|
1104
|
+
),
|
|
1105
|
+
raw_data=reaction_data,
|
|
1106
|
+
)
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
return reactions
|
|
1110
|
+
|
|
1111
|
+
except httpx.HTTPError as e:
|
|
1112
|
+
raise PlatformError(
|
|
1113
|
+
f"Failed to get reactions: {e}",
|
|
1114
|
+
platform=self.platform_name,
|
|
1115
|
+
) from e
|
|
1116
|
+
|
|
1117
|
+
async def add_reaction(
|
|
1118
|
+
self,
|
|
1119
|
+
entity_urn: str,
|
|
1120
|
+
reaction_type: ReactionType = ReactionType.LIKE,
|
|
1121
|
+
) -> bool:
|
|
1122
|
+
"""Add a reaction to a post or comment.
|
|
1123
|
+
|
|
1124
|
+
Args:
|
|
1125
|
+
entity_urn: URN of the entity (share, ugcPost, or comment).
|
|
1126
|
+
reaction_type: Type of reaction to add.
|
|
1127
|
+
|
|
1128
|
+
Returns:
|
|
1129
|
+
True if reaction was added successfully.
|
|
1130
|
+
|
|
1131
|
+
Example:
|
|
1132
|
+
>>> await client.add_reaction("urn:li:share:12345", ReactionType.PRAISE)
|
|
1133
|
+
"""
|
|
1134
|
+
if not self.api_client:
|
|
1135
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
1136
|
+
|
|
1137
|
+
if not self.api_client._client:
|
|
1138
|
+
raise RuntimeError("API client not initialized")
|
|
1139
|
+
|
|
1140
|
+
try:
|
|
1141
|
+
encoded_actor = quote(self._ensure_author_urn(), safe="")
|
|
1142
|
+
|
|
1143
|
+
# Build reaction payload
|
|
1144
|
+
payload = {
|
|
1145
|
+
"root": entity_urn,
|
|
1146
|
+
"reactionType": reaction_type.value,
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
await self.api_client._client.post(
|
|
1150
|
+
f"{self.base_url}/reactions",
|
|
1151
|
+
params={"actor": encoded_actor},
|
|
1152
|
+
json=payload,
|
|
1153
|
+
headers=self._build_auth_headers(),
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
return True
|
|
1157
|
+
|
|
1158
|
+
except httpx.HTTPError as e:
|
|
1159
|
+
raise PlatformError(
|
|
1160
|
+
f"Failed to add reaction: {e}",
|
|
1161
|
+
platform=self.platform_name,
|
|
1162
|
+
) from e
|
|
1163
|
+
|
|
1164
|
+
async def remove_reaction(self, entity_urn: str) -> bool:
|
|
1165
|
+
"""Remove your reaction from a post or comment.
|
|
1166
|
+
|
|
1167
|
+
Args:
|
|
1168
|
+
entity_urn: URN of the entity (share, ugcPost, or comment).
|
|
1169
|
+
|
|
1170
|
+
Returns:
|
|
1171
|
+
True if reaction was removed successfully.
|
|
1172
|
+
|
|
1173
|
+
Example:
|
|
1174
|
+
>>> await client.remove_reaction("urn:li:share:12345")
|
|
1175
|
+
"""
|
|
1176
|
+
if not self.api_client:
|
|
1177
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
1178
|
+
|
|
1179
|
+
if not self.api_client._client:
|
|
1180
|
+
raise RuntimeError("API client not initialized")
|
|
1181
|
+
|
|
1182
|
+
try:
|
|
1183
|
+
encoded_actor = quote(self._ensure_author_urn(), safe="")
|
|
1184
|
+
encoded_entity = quote(entity_urn, safe="")
|
|
1185
|
+
|
|
1186
|
+
await self.api_client._client.delete(
|
|
1187
|
+
f"{self.base_url}/reactions/(actor:{encoded_actor},entity:{encoded_entity})",
|
|
1188
|
+
headers=self._build_auth_headers(),
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
return True
|
|
1192
|
+
|
|
1193
|
+
except httpx.HTTPError as e:
|
|
1194
|
+
raise PlatformError(
|
|
1195
|
+
f"Failed to remove reaction: {e}",
|
|
1196
|
+
platform=self.platform_name,
|
|
1197
|
+
) from e
|
|
1198
|
+
|
|
1199
|
+
# ==================== Social Metadata API Methods ====================
|
|
1200
|
+
|
|
1201
|
+
async def get_social_metadata(self, entity_urn: str) -> SocialMetadata:
|
|
1202
|
+
"""Get social metadata (engagement summary) for a post or comment.
|
|
1203
|
+
|
|
1204
|
+
Returns aggregated engagement data including reaction counts by type
|
|
1205
|
+
and comment counts.
|
|
1206
|
+
|
|
1207
|
+
Args:
|
|
1208
|
+
entity_urn: URN of the entity (share, ugcPost, or comment).
|
|
1209
|
+
|
|
1210
|
+
Returns:
|
|
1211
|
+
SocialMetadata object with engagement summary.
|
|
1212
|
+
|
|
1213
|
+
Example:
|
|
1214
|
+
>>> metadata = await client.get_social_metadata("urn:li:share:12345")
|
|
1215
|
+
>>> print(f"Likes: {metadata.reaction_summaries.get(ReactionType.LIKE, 0)}")
|
|
1216
|
+
>>> print(f"Comments: {metadata.comment_count}")
|
|
1217
|
+
"""
|
|
1218
|
+
if not self.api_client:
|
|
1219
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
1220
|
+
|
|
1221
|
+
try:
|
|
1222
|
+
encoded_entity = quote(entity_urn, safe="")
|
|
1223
|
+
|
|
1224
|
+
response = await self.api_client.get(f"/socialMetadata/{encoded_entity}")
|
|
1225
|
+
data = response.data
|
|
1226
|
+
|
|
1227
|
+
# Parse reaction summaries
|
|
1228
|
+
reaction_summaries: dict[ReactionType, int] = {}
|
|
1229
|
+
for reaction_type, summary in data.get("reactionSummaries", {}).items():
|
|
1230
|
+
with contextlib.suppress(ValueError):
|
|
1231
|
+
reaction_summaries[ReactionType(reaction_type)] = summary.get(
|
|
1232
|
+
"count", 0
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
# Parse comment summary
|
|
1236
|
+
comment_summary = data.get("commentSummary", {})
|
|
1237
|
+
|
|
1238
|
+
return SocialMetadata(
|
|
1239
|
+
entity=data.get("entity", entity_urn),
|
|
1240
|
+
reaction_summaries=reaction_summaries,
|
|
1241
|
+
comment_count=comment_summary.get("count", 0),
|
|
1242
|
+
top_level_comment_count=comment_summary.get("topLevelCount", 0),
|
|
1243
|
+
comments_state=CommentsState(data.get("commentsState", "OPEN")),
|
|
1244
|
+
raw_data=data,
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
except httpx.HTTPError as e:
|
|
1248
|
+
raise PlatformError(
|
|
1249
|
+
f"Failed to get social metadata: {e}",
|
|
1250
|
+
platform=self.platform_name,
|
|
1251
|
+
) from e
|
|
1252
|
+
|
|
1253
|
+
async def set_comments_enabled(self, post_urn: str, enabled: bool) -> bool:
|
|
1254
|
+
"""Enable or disable comments on a post.
|
|
1255
|
+
|
|
1256
|
+
WARNING: Disabling comments will DELETE all existing comments on the post.
|
|
1257
|
+
|
|
1258
|
+
Args:
|
|
1259
|
+
post_urn: URN of the post (share or ugcPost).
|
|
1260
|
+
enabled: True to enable comments, False to disable.
|
|
1261
|
+
|
|
1262
|
+
Returns:
|
|
1263
|
+
True if the operation was successful.
|
|
1264
|
+
|
|
1265
|
+
Example:
|
|
1266
|
+
>>> # Disable comments (deletes existing comments!)
|
|
1267
|
+
>>> await client.set_comments_enabled("urn:li:share:12345", False)
|
|
1268
|
+
"""
|
|
1269
|
+
if not self.api_client:
|
|
1270
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
1271
|
+
|
|
1272
|
+
if not self.api_client._client:
|
|
1273
|
+
raise RuntimeError("API client not initialized")
|
|
1274
|
+
|
|
1275
|
+
try:
|
|
1276
|
+
encoded_post = quote(post_urn, safe="")
|
|
1277
|
+
encoded_actor = quote(self._ensure_author_urn(), safe="")
|
|
1278
|
+
|
|
1279
|
+
payload = {
|
|
1280
|
+
"patch": {
|
|
1281
|
+
"$set": {
|
|
1282
|
+
"commentsState": "OPEN" if enabled else "CLOSED",
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
await self.api_client._client.post(
|
|
1288
|
+
f"{self.base_url}/socialMetadata/{encoded_post}",
|
|
1289
|
+
params={"actor": encoded_actor},
|
|
1290
|
+
json=payload,
|
|
1291
|
+
headers=self._build_auth_headers(),
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
return True
|
|
1295
|
+
|
|
1296
|
+
except httpx.HTTPError as e:
|
|
1297
|
+
raise PlatformError(
|
|
1298
|
+
f"Failed to update comments state: {e}",
|
|
1299
|
+
platform=self.platform_name,
|
|
1300
|
+
) from e
|
|
1301
|
+
|
|
1302
|
+
# ==================== Organization Management Methods ====================
|
|
1303
|
+
|
|
1304
|
+
async def get_organization(self, org_id: str) -> Organization:
|
|
1305
|
+
"""Get organization (Company Page) details.
|
|
1306
|
+
|
|
1307
|
+
Requires administrator access to the organization for full details.
|
|
1308
|
+
|
|
1309
|
+
Args:
|
|
1310
|
+
org_id: Organization ID (numeric) or URN (urn:li:organization:12345).
|
|
1311
|
+
|
|
1312
|
+
Returns:
|
|
1313
|
+
Organization object with company details.
|
|
1314
|
+
|
|
1315
|
+
Example:
|
|
1316
|
+
>>> org = await client.get_organization("12345")
|
|
1317
|
+
>>> print(f"Company: {org.name}")
|
|
1318
|
+
"""
|
|
1319
|
+
if not self.api_client:
|
|
1320
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
1321
|
+
|
|
1322
|
+
if not self.api_client._client:
|
|
1323
|
+
raise RuntimeError("API client not initialized")
|
|
1324
|
+
|
|
1325
|
+
try:
|
|
1326
|
+
# Extract numeric ID if URN is provided
|
|
1327
|
+
if org_id.startswith("urn:li:organization:"):
|
|
1328
|
+
numeric_id = org_id.split(":")[-1]
|
|
1329
|
+
else:
|
|
1330
|
+
numeric_id = org_id
|
|
1331
|
+
|
|
1332
|
+
response = await self.api_client._client.get(
|
|
1333
|
+
f"{self.base_url}/organizations/{numeric_id}",
|
|
1334
|
+
headers=self._build_auth_headers(),
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
data = response.json()
|
|
1338
|
+
return self._parse_organization(data)
|
|
1339
|
+
|
|
1340
|
+
except httpx.HTTPStatusError as e:
|
|
1341
|
+
if e.response.status_code == 404:
|
|
1342
|
+
raise PlatformError(
|
|
1343
|
+
f"Organization not found: {org_id}",
|
|
1344
|
+
platform=self.platform_name,
|
|
1345
|
+
) from e
|
|
1346
|
+
if e.response.status_code == 403:
|
|
1347
|
+
raise PlatformAuthError(
|
|
1348
|
+
f"Not authorized to access organization: {org_id}",
|
|
1349
|
+
platform=self.platform_name,
|
|
1350
|
+
) from e
|
|
1351
|
+
raise PlatformError(
|
|
1352
|
+
f"Failed to get organization: {e}",
|
|
1353
|
+
platform=self.platform_name,
|
|
1354
|
+
) from e
|
|
1355
|
+
except httpx.HTTPError as e:
|
|
1356
|
+
raise PlatformError(
|
|
1357
|
+
f"Failed to get organization: {e}",
|
|
1358
|
+
platform=self.platform_name,
|
|
1359
|
+
) from e
|
|
1360
|
+
|
|
1361
|
+
async def get_organization_by_vanity(self, vanity_name: str) -> Organization:
|
|
1362
|
+
"""Find an organization by its vanity name (URL slug).
|
|
1363
|
+
|
|
1364
|
+
Args:
|
|
1365
|
+
vanity_name: Organization's vanity name (e.g., "linkedin" for
|
|
1366
|
+
linkedin.com/company/linkedin).
|
|
1367
|
+
|
|
1368
|
+
Returns:
|
|
1369
|
+
Organization object.
|
|
1370
|
+
|
|
1371
|
+
Example:
|
|
1372
|
+
>>> org = await client.get_organization_by_vanity("linkedin")
|
|
1373
|
+
"""
|
|
1374
|
+
if not self.api_client:
|
|
1375
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
1376
|
+
|
|
1377
|
+
if not self.api_client._client:
|
|
1378
|
+
raise RuntimeError("API client not initialized")
|
|
1379
|
+
|
|
1380
|
+
try:
|
|
1381
|
+
response = await self.api_client._client.get(
|
|
1382
|
+
f"{self.base_url}/organizations",
|
|
1383
|
+
params={
|
|
1384
|
+
"q": "vanityName",
|
|
1385
|
+
"vanityName": vanity_name,
|
|
1386
|
+
},
|
|
1387
|
+
headers=self._build_auth_headers(),
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
data = response.json()
|
|
1391
|
+
elements = data.get("elements", [])
|
|
1392
|
+
|
|
1393
|
+
if not elements:
|
|
1394
|
+
raise PlatformError(
|
|
1395
|
+
f"Organization not found with vanity name: {vanity_name}",
|
|
1396
|
+
platform=self.platform_name,
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
return self._parse_organization(elements[0])
|
|
1400
|
+
|
|
1401
|
+
except httpx.HTTPError as e:
|
|
1402
|
+
raise PlatformError(
|
|
1403
|
+
f"Failed to find organization: {e}",
|
|
1404
|
+
platform=self.platform_name,
|
|
1405
|
+
) from e
|
|
1406
|
+
|
|
1407
|
+
async def get_organization_followers(self, org_id: str) -> int:
|
|
1408
|
+
"""Get the follower count for an organization.
|
|
1409
|
+
|
|
1410
|
+
Args:
|
|
1411
|
+
org_id: Organization ID (numeric) or URN.
|
|
1412
|
+
|
|
1413
|
+
Returns:
|
|
1414
|
+
Number of followers.
|
|
1415
|
+
|
|
1416
|
+
Example:
|
|
1417
|
+
>>> followers = await client.get_organization_followers("12345")
|
|
1418
|
+
>>> print(f"Followers: {followers}")
|
|
1419
|
+
"""
|
|
1420
|
+
if not self.api_client:
|
|
1421
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
1422
|
+
|
|
1423
|
+
if not self.api_client._client:
|
|
1424
|
+
raise RuntimeError("API client not initialized")
|
|
1425
|
+
|
|
1426
|
+
try:
|
|
1427
|
+
# Build organization URN if needed
|
|
1428
|
+
if org_id.startswith("urn:li:organization:"):
|
|
1429
|
+
org_urn = org_id
|
|
1430
|
+
else:
|
|
1431
|
+
org_urn = f"urn:li:organization:{org_id}"
|
|
1432
|
+
|
|
1433
|
+
encoded_urn = quote(org_urn, safe="")
|
|
1434
|
+
|
|
1435
|
+
response = await self.api_client._client.get(
|
|
1436
|
+
f"{self.base_url}/networkSizes/{encoded_urn}",
|
|
1437
|
+
params={"edgeType": "COMPANY_FOLLOWED_BY_MEMBER"},
|
|
1438
|
+
headers=self._build_auth_headers(),
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
data = response.json()
|
|
1442
|
+
return data.get("firstDegreeSize", 0)
|
|
1443
|
+
|
|
1444
|
+
except httpx.HTTPError as e:
|
|
1445
|
+
raise PlatformError(
|
|
1446
|
+
f"Failed to get organization followers: {e}",
|
|
1447
|
+
platform=self.platform_name,
|
|
1448
|
+
) from e
|
|
1449
|
+
|
|
1450
|
+
def _parse_organization(self, data: dict[str, Any]) -> Organization:
|
|
1451
|
+
"""Parse LinkedIn API response into Organization model.
|
|
1452
|
+
|
|
1453
|
+
Args:
|
|
1454
|
+
data: Raw API response data.
|
|
1455
|
+
|
|
1456
|
+
Returns:
|
|
1457
|
+
Organization object.
|
|
1458
|
+
"""
|
|
1459
|
+
# Build organization URN
|
|
1460
|
+
org_id = data.get("id")
|
|
1461
|
+
if org_id and not str(org_id).startswith("urn:"):
|
|
1462
|
+
org_id = f"urn:li:organization:{org_id}"
|
|
1463
|
+
|
|
1464
|
+
# Extract localized name
|
|
1465
|
+
localized_name = data.get("localizedName", "")
|
|
1466
|
+
if not localized_name:
|
|
1467
|
+
# Try to get from name field
|
|
1468
|
+
name_obj = data.get("name", {})
|
|
1469
|
+
if isinstance(name_obj, dict):
|
|
1470
|
+
# Get first locale
|
|
1471
|
+
localized = name_obj.get("localized", {})
|
|
1472
|
+
if localized:
|
|
1473
|
+
localized_name = next(iter(localized.values()), "")
|
|
1474
|
+
else:
|
|
1475
|
+
localized_name = str(name_obj)
|
|
1476
|
+
|
|
1477
|
+
# Extract logo URL
|
|
1478
|
+
logo_url = None
|
|
1479
|
+
logo_v2 = data.get("logoV2", {})
|
|
1480
|
+
if logo_v2:
|
|
1481
|
+
# Try to get the original image URL
|
|
1482
|
+
original = logo_v2.get("original", "")
|
|
1483
|
+
if original:
|
|
1484
|
+
logo_url = original
|
|
1485
|
+
|
|
1486
|
+
# Map organization type
|
|
1487
|
+
org_type = None
|
|
1488
|
+
primary_type = data.get("primaryOrganizationType")
|
|
1489
|
+
if primary_type:
|
|
1490
|
+
with contextlib.suppress(ValueError):
|
|
1491
|
+
org_type = OrganizationType(primary_type)
|
|
1492
|
+
|
|
1493
|
+
return Organization(
|
|
1494
|
+
id=str(org_id) if org_id else "",
|
|
1495
|
+
name=(
|
|
1496
|
+
data.get("name", localized_name)
|
|
1497
|
+
if isinstance(data.get("name"), str)
|
|
1498
|
+
else localized_name
|
|
1499
|
+
),
|
|
1500
|
+
localized_name=localized_name,
|
|
1501
|
+
vanity_name=data.get("vanityName"),
|
|
1502
|
+
logo_url=logo_url,
|
|
1503
|
+
follower_count=data.get("followerCount"),
|
|
1504
|
+
primary_type=org_type,
|
|
1505
|
+
website_url=data.get("websiteUrl"),
|
|
1506
|
+
description=data.get("description"),
|
|
1507
|
+
industry=data.get("industry"),
|
|
1508
|
+
raw_data=data,
|
|
1509
|
+
)
|
|
1510
|
+
|
|
668
1511
|
# ==================== Helper Methods ====================
|
|
669
1512
|
|
|
1513
|
+
def _ensure_author_urn(self) -> str:
|
|
1514
|
+
"""Ensure author URN is set and return it.
|
|
1515
|
+
|
|
1516
|
+
Returns:
|
|
1517
|
+
The author URN string.
|
|
1518
|
+
|
|
1519
|
+
Raises:
|
|
1520
|
+
PlatformAuthError: If author URN is not set.
|
|
1521
|
+
"""
|
|
1522
|
+
if not self.author_urn:
|
|
1523
|
+
raise PlatformAuthError(
|
|
1524
|
+
"Author URN (user_id) is required but not set in credentials",
|
|
1525
|
+
platform=self.platform_name,
|
|
1526
|
+
)
|
|
1527
|
+
return self.author_urn
|
|
1528
|
+
|
|
670
1529
|
def _parse_post(self, data: dict[str, Any]) -> Post:
|
|
671
1530
|
"""Parse LinkedIn API response into Post model.
|
|
672
1531
|
|
|
1532
|
+
Supports both the new REST API format (Community Management API)
|
|
1533
|
+
and the legacy UGC API format for backwards compatibility.
|
|
1534
|
+
|
|
673
1535
|
Args:
|
|
674
1536
|
data: Raw API response data.
|
|
675
1537
|
|
|
676
1538
|
Returns:
|
|
677
1539
|
Post object.
|
|
678
1540
|
"""
|
|
679
|
-
|
|
680
|
-
|
|
1541
|
+
# New REST API format uses "commentary" directly
|
|
1542
|
+
content = data.get("commentary", "")
|
|
1543
|
+
|
|
1544
|
+
# Fallback to old UGC format if needed
|
|
1545
|
+
if not content and "specificContent" in data:
|
|
681
1546
|
share_content = data["specificContent"].get(
|
|
682
1547
|
"com.linkedin.ugc.ShareContent", {}
|
|
683
1548
|
)
|
|
684
1549
|
commentary = share_content.get("shareCommentary", {})
|
|
685
1550
|
content = commentary.get("text", "")
|
|
686
1551
|
|
|
687
|
-
# Extract timestamps
|
|
688
|
-
created_timestamp = data.get("
|
|
1552
|
+
# Extract timestamps - REST API uses createdAt/publishedAt in milliseconds
|
|
1553
|
+
created_timestamp = data.get("createdAt") or data.get("publishedAt")
|
|
1554
|
+
if not created_timestamp:
|
|
1555
|
+
# Legacy format: nested object
|
|
1556
|
+
created_timestamp = data.get("created", {}).get("time", 0)
|
|
1557
|
+
|
|
689
1558
|
created_at = (
|
|
690
1559
|
datetime.fromtimestamp(created_timestamp / 1000)
|
|
691
1560
|
if created_timestamp
|
|
692
1561
|
else datetime.now()
|
|
693
1562
|
)
|
|
694
1563
|
|
|
1564
|
+
# Extract updated timestamp if available
|
|
1565
|
+
updated_timestamp = data.get("lastModifiedAt")
|
|
1566
|
+
updated_at = (
|
|
1567
|
+
datetime.fromtimestamp(updated_timestamp / 1000)
|
|
1568
|
+
if updated_timestamp
|
|
1569
|
+
else None
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
# Map lifecycle state to post status
|
|
1573
|
+
lifecycle_state = data.get("lifecycleState", "PUBLISHED")
|
|
1574
|
+
status_map = {
|
|
1575
|
+
"PUBLISHED": PostStatus.PUBLISHED,
|
|
1576
|
+
"DRAFT": PostStatus.DRAFT,
|
|
1577
|
+
"PUBLISH_REQUESTED": PostStatus.SCHEDULED,
|
|
1578
|
+
"PUBLISH_FAILED": PostStatus.FAILED,
|
|
1579
|
+
}
|
|
1580
|
+
status = status_map.get(lifecycle_state, PostStatus.DRAFT)
|
|
1581
|
+
|
|
695
1582
|
return Post(
|
|
696
1583
|
post_id=data["id"],
|
|
697
1584
|
platform=self.platform_name,
|
|
698
1585
|
content=content,
|
|
699
1586
|
media=[], # Media parsing would go here
|
|
700
|
-
status=
|
|
701
|
-
PostStatus.PUBLISHED
|
|
702
|
-
if data.get("lifecycleState") == "PUBLISHED"
|
|
703
|
-
else PostStatus.DRAFT
|
|
704
|
-
),
|
|
1587
|
+
status=status,
|
|
705
1588
|
created_at=created_at,
|
|
1589
|
+
updated_at=updated_at,
|
|
706
1590
|
author_id=data.get("author"),
|
|
707
1591
|
raw_data=data,
|
|
708
1592
|
)
|