marqetive-lib 0.1.17__tar.gz → 0.1.18__tar.gz
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_lib-0.1.17 → marqetive_lib-0.1.18}/PKG-INFO +1 -1
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/pyproject.toml +1 -1
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/linkedin/client.py +387 -28
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/linkedin/media.py +320 -211
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/README.md +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/__init__.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/core/__init__.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/core/base.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/core/client.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/core/exceptions.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/core/models.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/factory.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/__init__.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/instagram/__init__.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/instagram/client.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/instagram/exceptions.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/instagram/media.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/instagram/models.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/linkedin/__init__.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/linkedin/exceptions.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/linkedin/models.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/tiktok/__init__.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/tiktok/client.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/tiktok/exceptions.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/tiktok/media.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/tiktok/models.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/twitter/__init__.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/twitter/client.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/twitter/exceptions.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/twitter/media.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/platforms/twitter/models.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/py.typed +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/utils/__init__.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/utils/file_handlers.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/utils/helpers.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/utils/media.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/utils/oauth.py +0 -0
- {marqetive_lib-0.1.17 → marqetive_lib-0.1.18}/src/marqetive/utils/retry.py +0 -0
|
@@ -46,6 +46,24 @@ from marqetive.platforms.linkedin.models import (
|
|
|
46
46
|
SocialMetadata,
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
+
# Valid CTA (Call-To-Action) labels per LinkedIn API documentation
|
|
50
|
+
# Note: BUY_NOW and SHOP_NOW require API version 202504 or later
|
|
51
|
+
VALID_CTA_LABELS = frozenset({
|
|
52
|
+
"APPLY",
|
|
53
|
+
"DOWNLOAD",
|
|
54
|
+
"VIEW_QUOTE",
|
|
55
|
+
"LEARN_MORE",
|
|
56
|
+
"SIGN_UP",
|
|
57
|
+
"SUBSCRIBE",
|
|
58
|
+
"REGISTER",
|
|
59
|
+
"JOIN",
|
|
60
|
+
"ATTEND",
|
|
61
|
+
"REQUEST_DEMO",
|
|
62
|
+
"SEE_MORE",
|
|
63
|
+
"BUY_NOW", # Requires API version 202504+
|
|
64
|
+
"SHOP_NOW", # Requires API version 202504+
|
|
65
|
+
})
|
|
66
|
+
|
|
49
67
|
|
|
50
68
|
class LinkedInClient(SocialMediaPlatform):
|
|
51
69
|
"""LinkedIn API client using the Community Management API.
|
|
@@ -307,29 +325,111 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
307
325
|
"""Create and publish a LinkedIn post.
|
|
308
326
|
|
|
309
327
|
Uses the Community Management API to create posts on personal profiles
|
|
310
|
-
or organization pages.
|
|
328
|
+
or organization pages. Supports text, images, videos, documents,
|
|
329
|
+
multi-image posts, articles, and Direct Sponsored Content (dark posts).
|
|
311
330
|
|
|
312
331
|
Args:
|
|
313
|
-
request: Post creation request
|
|
314
|
-
|
|
332
|
+
request: Post creation request with the following fields:
|
|
333
|
+
- content: Post text (max 3000 chars). Supports mentions and hashtags.
|
|
334
|
+
- link: URL for article posts.
|
|
335
|
+
- media_ids: List of media URNs (images, videos, documents).
|
|
336
|
+
- additional_data: LinkedIn-specific options (see below).
|
|
337
|
+
|
|
338
|
+
Additional Data Options:
|
|
339
|
+
Basic options:
|
|
340
|
+
- visibility: "PUBLIC" (default), "CONNECTIONS", "LOGGED_IN"
|
|
341
|
+
- feed_distribution: "MAIN_FEED" (default), "NONE" (for dark posts)
|
|
342
|
+
- disable_reshare: bool (default False)
|
|
343
|
+
|
|
344
|
+
Call-to-action:
|
|
345
|
+
- call_to_action: CTA label (APPLY, DOWNLOAD, VIEW_QUOTE, LEARN_MORE,
|
|
346
|
+
SIGN_UP, SUBSCRIBE, REGISTER, JOIN, ATTEND, REQUEST_DEMO,
|
|
347
|
+
SEE_MORE, BUY_NOW, SHOP_NOW)
|
|
348
|
+
- landing_page: URL for CTA button
|
|
349
|
+
|
|
350
|
+
Media options:
|
|
351
|
+
- media_title: Title for single media
|
|
352
|
+
- media_alt_text: Alt text for single media
|
|
353
|
+
- media_alt_texts: List of alt texts for multi-image posts
|
|
354
|
+
|
|
355
|
+
Article options:
|
|
356
|
+
- article_title: Title for link preview
|
|
357
|
+
- article_description: Description for link preview
|
|
358
|
+
- article_thumbnail: Image URN for article thumbnail
|
|
359
|
+
|
|
360
|
+
Targeting (for targeted posts):
|
|
361
|
+
- target_entities: List of targeting criteria
|
|
362
|
+
|
|
363
|
+
Direct Sponsored Content (dark posts):
|
|
364
|
+
- ad_context: Dict with DSC configuration:
|
|
365
|
+
- is_dsc: True for dark posts
|
|
366
|
+
- dsc_ad_type: VIDEO, STANDARD, CAROUSEL, JOB_POSTING,
|
|
367
|
+
NATIVE_DOCUMENT, EVENT
|
|
368
|
+
- dsc_status: ACTIVE, ARCHIVED
|
|
369
|
+
- dsc_ad_account: Sponsored account URN
|
|
370
|
+
- dsc_name: Display name for the DSC
|
|
371
|
+
|
|
372
|
+
Mentions and Hashtags:
|
|
373
|
+
Use these formats in the content field:
|
|
374
|
+
- Organization mention: @[Display Name](urn:li:organization:12345)
|
|
375
|
+
- Person mention: @[Jane Smith](urn:li:person:abc123)
|
|
376
|
+
- Hashtag: #coding (plain text, auto-formatted by LinkedIn)
|
|
377
|
+
|
|
378
|
+
Note: Organization names in mentions must match exactly (case-sensitive).
|
|
315
379
|
|
|
316
380
|
Returns:
|
|
317
381
|
Created Post object.
|
|
318
382
|
|
|
319
383
|
Raises:
|
|
320
|
-
ValidationError: If request is invalid.
|
|
384
|
+
ValidationError: If request is invalid or CTA label is invalid.
|
|
321
385
|
MediaUploadError: If media upload fails.
|
|
322
386
|
|
|
323
387
|
Example:
|
|
388
|
+
>>> # Text post with link and CTA
|
|
324
389
|
>>> request = PostCreateRequest(
|
|
325
390
|
... content="Check out our new product!",
|
|
326
391
|
... link="https://example.com/product",
|
|
327
392
|
... additional_data={
|
|
328
393
|
... "visibility": "PUBLIC",
|
|
329
|
-
... "call_to_action": "LEARN_MORE"
|
|
394
|
+
... "call_to_action": "LEARN_MORE",
|
|
395
|
+
... "landing_page": "https://example.com/signup"
|
|
396
|
+
... }
|
|
397
|
+
... )
|
|
398
|
+
>>> post = await client.create_post(request)
|
|
399
|
+
>>>
|
|
400
|
+
>>> # Multi-image post
|
|
401
|
+
>>> img1 = await client.upload_image("photo1.jpg")
|
|
402
|
+
>>> img2 = await client.upload_image("photo2.jpg")
|
|
403
|
+
>>> request = PostCreateRequest(
|
|
404
|
+
... content="Check out these photos!",
|
|
405
|
+
... media_ids=[img1.asset_id, img2.asset_id],
|
|
406
|
+
... additional_data={
|
|
407
|
+
... "media_alt_texts": ["First photo", "Second photo"]
|
|
408
|
+
... }
|
|
409
|
+
... )
|
|
410
|
+
>>> post = await client.create_post(request)
|
|
411
|
+
>>>
|
|
412
|
+
>>> # Dark post (Direct Sponsored Content)
|
|
413
|
+
>>> request = PostCreateRequest(
|
|
414
|
+
... content="Sponsored content",
|
|
415
|
+
... media_ids=[video_asset.asset_id],
|
|
416
|
+
... additional_data={
|
|
417
|
+
... "ad_context": {
|
|
418
|
+
... "is_dsc": True,
|
|
419
|
+
... "dsc_ad_type": "VIDEO",
|
|
420
|
+
... "dsc_status": "ACTIVE",
|
|
421
|
+
... "dsc_ad_account": "urn:li:sponsoredAccount:123"
|
|
422
|
+
... }
|
|
330
423
|
... }
|
|
331
424
|
... )
|
|
332
425
|
>>> post = await client.create_post(request)
|
|
426
|
+
>>>
|
|
427
|
+
>>> # Post with mentions and hashtags
|
|
428
|
+
>>> request = PostCreateRequest(
|
|
429
|
+
... content="Excited to announce our partnership with "
|
|
430
|
+
... "@[LinkedIn](urn:li:organization:1337)! #exciting #partnership"
|
|
431
|
+
... )
|
|
432
|
+
>>> post = await client.create_post(request)
|
|
333
433
|
"""
|
|
334
434
|
if not self.api_client:
|
|
335
435
|
raise RuntimeError("Client must be used as async context manager")
|
|
@@ -370,23 +470,37 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
370
470
|
),
|
|
371
471
|
}
|
|
372
472
|
|
|
373
|
-
# Add media content if provided
|
|
473
|
+
# Add media content if provided (images, videos, or documents)
|
|
374
474
|
if request.media_ids:
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
"
|
|
381
|
-
|
|
475
|
+
if len(request.media_ids) > 1:
|
|
476
|
+
# Multi-image post (organic only, not for sponsored content)
|
|
477
|
+
# Per docs: use "multiImage" content type with array of images
|
|
478
|
+
images = []
|
|
479
|
+
for media_id in request.media_ids:
|
|
480
|
+
image_entry: dict[str, Any] = {"id": media_id}
|
|
481
|
+
# Alt text can be provided per image via additional_data
|
|
482
|
+
alt_texts = request.additional_data.get("media_alt_texts", [])
|
|
483
|
+
if alt_texts and len(alt_texts) > len(images):
|
|
484
|
+
image_entry["altText"] = alt_texts[len(images)]
|
|
485
|
+
images.append(image_entry)
|
|
486
|
+
|
|
487
|
+
post_payload["content"] = {"multiImage": {"images": images}}
|
|
488
|
+
else:
|
|
489
|
+
# Single media (image, video, or document)
|
|
490
|
+
media_id = request.media_ids[0]
|
|
491
|
+
post_payload["content"] = {
|
|
492
|
+
"media": {
|
|
493
|
+
"id": media_id,
|
|
494
|
+
"title": request.additional_data.get("media_title"),
|
|
495
|
+
"altText": request.additional_data.get("media_alt_text"),
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
# Remove None values
|
|
499
|
+
post_payload["content"]["media"] = {
|
|
500
|
+
k: v
|
|
501
|
+
for k, v in post_payload["content"]["media"].items()
|
|
502
|
+
if v is not None
|
|
382
503
|
}
|
|
383
|
-
}
|
|
384
|
-
# Remove None values
|
|
385
|
-
post_payload["content"]["media"] = {
|
|
386
|
-
k: v
|
|
387
|
-
for k, v in post_payload["content"]["media"].items()
|
|
388
|
-
if v is not None
|
|
389
|
-
}
|
|
390
504
|
|
|
391
505
|
# Add article/link if provided (and no media)
|
|
392
506
|
elif request.link:
|
|
@@ -411,10 +525,40 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
411
525
|
|
|
412
526
|
# Add call-to-action if provided
|
|
413
527
|
if cta := request.additional_data.get("call_to_action"):
|
|
414
|
-
|
|
528
|
+
cta_upper = cta.upper()
|
|
529
|
+
if cta_upper not in VALID_CTA_LABELS:
|
|
530
|
+
raise ValidationError(
|
|
531
|
+
f"Invalid call_to_action: '{cta}'. "
|
|
532
|
+
f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
|
|
533
|
+
platform=self.platform_name,
|
|
534
|
+
field="call_to_action",
|
|
535
|
+
)
|
|
536
|
+
post_payload["contentCallToActionLabel"] = cta_upper
|
|
415
537
|
if landing_page := request.additional_data.get("landing_page"):
|
|
416
538
|
post_payload["contentLandingPage"] = landing_page
|
|
417
539
|
|
|
540
|
+
# Add adContext for Direct Sponsored Content (DSC) / dark posts
|
|
541
|
+
# Dark posts don't appear on company page but can be used in ad campaigns
|
|
542
|
+
if ad_context := request.additional_data.get("ad_context"):
|
|
543
|
+
post_payload["adContext"] = {}
|
|
544
|
+
if ad_context.get("is_dsc"):
|
|
545
|
+
post_payload["adContext"]["isDsc"] = True
|
|
546
|
+
if dsc_ad_type := ad_context.get("dsc_ad_type"):
|
|
547
|
+
# Valid types: VIDEO, STANDARD, CAROUSEL, JOB_POSTING,
|
|
548
|
+
# NATIVE_DOCUMENT, EVENT
|
|
549
|
+
post_payload["adContext"]["dscAdType"] = dsc_ad_type
|
|
550
|
+
if dsc_status := ad_context.get("dsc_status"):
|
|
551
|
+
# Valid values: ACTIVE, ARCHIVED
|
|
552
|
+
post_payload["adContext"]["dscStatus"] = dsc_status
|
|
553
|
+
if dsc_ad_account := ad_context.get("dsc_ad_account"):
|
|
554
|
+
post_payload["adContext"]["dscAdAccount"] = dsc_ad_account
|
|
555
|
+
if dsc_name := ad_context.get("dsc_name"):
|
|
556
|
+
post_payload["adContext"]["dscName"] = dsc_name
|
|
557
|
+
|
|
558
|
+
# For dark posts, set feedDistribution to NONE
|
|
559
|
+
if ad_context.get("is_dsc"):
|
|
560
|
+
post_payload["distribution"]["feedDistribution"] = "NONE"
|
|
561
|
+
|
|
418
562
|
# Create the post
|
|
419
563
|
response = await self.api_client.post("/posts", data=post_payload)
|
|
420
564
|
|
|
@@ -534,7 +678,15 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
534
678
|
# Handle additional LinkedIn-specific fields
|
|
535
679
|
additional = getattr(request, "additional_data", {}) or {}
|
|
536
680
|
if cta := additional.get("call_to_action"):
|
|
537
|
-
|
|
681
|
+
cta_upper = cta.upper()
|
|
682
|
+
if cta_upper not in VALID_CTA_LABELS:
|
|
683
|
+
raise ValidationError(
|
|
684
|
+
f"Invalid call_to_action: '{cta}'. "
|
|
685
|
+
f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
|
|
686
|
+
platform=self.platform_name,
|
|
687
|
+
field="call_to_action",
|
|
688
|
+
)
|
|
689
|
+
patch_payload["patch"]["$set"]["contentCallToActionLabel"] = cta_upper
|
|
538
690
|
if landing_page := additional.get("landing_page"):
|
|
539
691
|
patch_payload["patch"]["$set"]["contentLandingPage"] = landing_page
|
|
540
692
|
if lifecycle := additional.get("lifecycle_state"):
|
|
@@ -597,9 +749,13 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
597
749
|
raise RuntimeError("API client not initialized")
|
|
598
750
|
|
|
599
751
|
encoded_post_id = quote(post_id, safe="")
|
|
752
|
+
headers = {
|
|
753
|
+
**self._build_auth_headers(),
|
|
754
|
+
"X-RestLi-Method": "DELETE",
|
|
755
|
+
}
|
|
600
756
|
await self.api_client._client.delete(
|
|
601
757
|
f"{self.base_url}/posts/{encoded_post_id}",
|
|
602
|
-
headers=
|
|
758
|
+
headers=headers,
|
|
603
759
|
)
|
|
604
760
|
return True
|
|
605
761
|
|
|
@@ -693,6 +849,204 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
693
849
|
platform=self.platform_name,
|
|
694
850
|
) from e
|
|
695
851
|
|
|
852
|
+
async def create_reshare(
|
|
853
|
+
self,
|
|
854
|
+
parent_post_urn: str,
|
|
855
|
+
commentary: str | None = None,
|
|
856
|
+
visibility: str = "PUBLIC",
|
|
857
|
+
) -> Post:
|
|
858
|
+
"""Create a reshare (repost) of an existing LinkedIn post.
|
|
859
|
+
|
|
860
|
+
Args:
|
|
861
|
+
parent_post_urn: URN of the post to reshare (urn:li:share:xxx or urn:li:ugcPost:xxx).
|
|
862
|
+
commentary: Optional commentary to add to the reshare.
|
|
863
|
+
visibility: Post visibility (PUBLIC, CONNECTIONS, LOGGED_IN).
|
|
864
|
+
|
|
865
|
+
Returns:
|
|
866
|
+
Created Post object.
|
|
867
|
+
|
|
868
|
+
Raises:
|
|
869
|
+
ValidationError: If parent_post_urn is invalid.
|
|
870
|
+
|
|
871
|
+
Example:
|
|
872
|
+
>>> reshare = await client.create_reshare(
|
|
873
|
+
... "urn:li:share:6957408550713184256",
|
|
874
|
+
... commentary="Great insights!"
|
|
875
|
+
... )
|
|
876
|
+
"""
|
|
877
|
+
if not self.api_client:
|
|
878
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
879
|
+
|
|
880
|
+
try:
|
|
881
|
+
post_payload: dict[str, Any] = {
|
|
882
|
+
"author": self.author_urn,
|
|
883
|
+
"visibility": visibility,
|
|
884
|
+
"distribution": {
|
|
885
|
+
"feedDistribution": "MAIN_FEED",
|
|
886
|
+
"targetEntities": [],
|
|
887
|
+
"thirdPartyDistributionChannels": [],
|
|
888
|
+
},
|
|
889
|
+
"lifecycleState": "PUBLISHED",
|
|
890
|
+
"reshareContext": {
|
|
891
|
+
"parent": parent_post_urn,
|
|
892
|
+
},
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if commentary:
|
|
896
|
+
post_payload["commentary"] = commentary
|
|
897
|
+
|
|
898
|
+
response = await self.api_client.post("/posts", data=post_payload)
|
|
899
|
+
|
|
900
|
+
post_id = response.data.get("id") or response.headers.get("x-restli-id")
|
|
901
|
+
if not post_id:
|
|
902
|
+
raise PlatformError(
|
|
903
|
+
"Failed to get post ID from response",
|
|
904
|
+
platform=self.platform_name,
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
return Post(
|
|
908
|
+
post_id=post_id,
|
|
909
|
+
platform=self.platform_name,
|
|
910
|
+
content=commentary or "",
|
|
911
|
+
status=PostStatus.PUBLISHED,
|
|
912
|
+
created_at=datetime.now(),
|
|
913
|
+
author_id=self.author_urn,
|
|
914
|
+
raw_data=response.data,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
except httpx.HTTPError as e:
|
|
918
|
+
raise PlatformError(
|
|
919
|
+
f"Failed to create reshare: {e}",
|
|
920
|
+
platform=self.platform_name,
|
|
921
|
+
) from e
|
|
922
|
+
|
|
923
|
+
async def batch_get_posts(self, post_ids: list[str]) -> list[Post]:
|
|
924
|
+
"""Retrieve multiple posts by their URNs in a single request.
|
|
925
|
+
|
|
926
|
+
Uses the BATCH_GET method for efficient retrieval of multiple posts.
|
|
927
|
+
|
|
928
|
+
Args:
|
|
929
|
+
post_ids: List of post URNs (urn:li:share:xxx or urn:li:ugcPost:xxx).
|
|
930
|
+
|
|
931
|
+
Returns:
|
|
932
|
+
List of Post objects (in the same order as input if available).
|
|
933
|
+
|
|
934
|
+
Example:
|
|
935
|
+
>>> posts = await client.batch_get_posts([
|
|
936
|
+
... "urn:li:share:123",
|
|
937
|
+
... "urn:li:ugcPost:456"
|
|
938
|
+
... ])
|
|
939
|
+
"""
|
|
940
|
+
if not self.api_client:
|
|
941
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
942
|
+
|
|
943
|
+
if not self.api_client._client:
|
|
944
|
+
raise RuntimeError("API client not initialized")
|
|
945
|
+
|
|
946
|
+
if not post_ids:
|
|
947
|
+
return []
|
|
948
|
+
|
|
949
|
+
try:
|
|
950
|
+
# URL encode each post ID
|
|
951
|
+
encoded_ids = [quote(pid, safe="") for pid in post_ids]
|
|
952
|
+
ids_param = f"List({','.join(encoded_ids)})"
|
|
953
|
+
|
|
954
|
+
headers = {
|
|
955
|
+
**self._build_auth_headers(),
|
|
956
|
+
"X-RestLi-Method": "BATCH_GET",
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
response = await self.api_client._client.get(
|
|
960
|
+
f"{self.base_url}/posts",
|
|
961
|
+
params={"ids": ids_param},
|
|
962
|
+
headers=headers,
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
data = response.json()
|
|
966
|
+
results = data.get("results", {})
|
|
967
|
+
|
|
968
|
+
# Parse posts, maintaining order where possible
|
|
969
|
+
posts = []
|
|
970
|
+
for post_id in post_ids:
|
|
971
|
+
if post_id in results:
|
|
972
|
+
posts.append(self._parse_post(results[post_id]))
|
|
973
|
+
|
|
974
|
+
return posts
|
|
975
|
+
|
|
976
|
+
except httpx.HTTPError as e:
|
|
977
|
+
raise PlatformError(
|
|
978
|
+
f"Failed to batch get posts: {e}",
|
|
979
|
+
platform=self.platform_name,
|
|
980
|
+
) from e
|
|
981
|
+
|
|
982
|
+
async def list_dsc_posts(
|
|
983
|
+
self,
|
|
984
|
+
dsc_ad_account: str,
|
|
985
|
+
dsc_ad_types: list[str] | None = None,
|
|
986
|
+
limit: int = 10,
|
|
987
|
+
offset: int = 0,
|
|
988
|
+
) -> list[Post]:
|
|
989
|
+
"""List Direct Sponsored Content (DSC) posts by ad account.
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
dsc_ad_account: Sponsored account URN (urn:li:sponsoredAccount:xxx).
|
|
993
|
+
dsc_ad_types: Optional filter by DSC types (VIDEO, STANDARD, CAROUSEL,
|
|
994
|
+
JOB_POSTING, NATIVE_DOCUMENT, EVENT).
|
|
995
|
+
limit: Maximum number of posts to retrieve (max 100).
|
|
996
|
+
offset: Number of posts to skip for pagination.
|
|
997
|
+
|
|
998
|
+
Returns:
|
|
999
|
+
List of Post objects.
|
|
1000
|
+
|
|
1001
|
+
Example:
|
|
1002
|
+
>>> dsc_posts = await client.list_dsc_posts(
|
|
1003
|
+
... "urn:li:sponsoredAccount:520866471",
|
|
1004
|
+
... dsc_ad_types=["VIDEO", "STANDARD"]
|
|
1005
|
+
... )
|
|
1006
|
+
"""
|
|
1007
|
+
if not self.api_client:
|
|
1008
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
1009
|
+
|
|
1010
|
+
if not self.api_client._client:
|
|
1011
|
+
raise RuntimeError("API client not initialized")
|
|
1012
|
+
|
|
1013
|
+
try:
|
|
1014
|
+
encoded_account = quote(dsc_ad_account, safe="")
|
|
1015
|
+
|
|
1016
|
+
params: dict[str, Any] = {
|
|
1017
|
+
"dscAdAccount": encoded_account,
|
|
1018
|
+
"q": "dscAdAccount",
|
|
1019
|
+
"count": min(limit, 100),
|
|
1020
|
+
"start": offset,
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if dsc_ad_types:
|
|
1024
|
+
params["dscAdTypes"] = f"List({','.join(dsc_ad_types)})"
|
|
1025
|
+
|
|
1026
|
+
headers = {
|
|
1027
|
+
**self._build_auth_headers(),
|
|
1028
|
+
"X-RestLi-Method": "FINDER",
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
response = await self.api_client._client.get(
|
|
1032
|
+
f"{self.base_url}/posts",
|
|
1033
|
+
params=params,
|
|
1034
|
+
headers=headers,
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
posts = []
|
|
1038
|
+
data = response.json()
|
|
1039
|
+
for post_data in data.get("elements", []):
|
|
1040
|
+
posts.append(self._parse_post(post_data))
|
|
1041
|
+
|
|
1042
|
+
return posts
|
|
1043
|
+
|
|
1044
|
+
except httpx.HTTPError as e:
|
|
1045
|
+
raise PlatformError(
|
|
1046
|
+
f"Failed to list DSC posts: {e}",
|
|
1047
|
+
platform=self.platform_name,
|
|
1048
|
+
) from e
|
|
1049
|
+
|
|
696
1050
|
# ==================== Comment Methods ====================
|
|
697
1051
|
|
|
698
1052
|
async def get_comments(
|
|
@@ -1098,20 +1452,25 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
1098
1452
|
*,
|
|
1099
1453
|
title: str | None = None,
|
|
1100
1454
|
) -> MediaAsset:
|
|
1101
|
-
"""Upload a document
|
|
1455
|
+
"""Upload a document to LinkedIn using the Documents API.
|
|
1102
1456
|
|
|
1103
|
-
Convenience method for document uploads.
|
|
1457
|
+
Convenience method for document uploads. Supports PDF, PPT, PPTX, DOC, DOCX.
|
|
1104
1458
|
|
|
1105
1459
|
Args:
|
|
1106
|
-
file_path: Path to
|
|
1107
|
-
title: Document title.
|
|
1460
|
+
file_path: Path to document file or URL.
|
|
1461
|
+
title: Document title (reserved for future use).
|
|
1108
1462
|
|
|
1109
1463
|
Returns:
|
|
1110
|
-
MediaAsset with
|
|
1464
|
+
MediaAsset with document URN (urn:li:document:xxx).
|
|
1111
1465
|
|
|
1112
1466
|
Example:
|
|
1113
1467
|
>>> async with LinkedInClient(credentials) as client:
|
|
1114
1468
|
... asset = await client.upload_document("report.pdf")
|
|
1469
|
+
... request = PostCreateRequest(
|
|
1470
|
+
... content="Check out our report!",
|
|
1471
|
+
... media_ids=[asset.asset_id]
|
|
1472
|
+
... )
|
|
1473
|
+
... post = await client.create_post(request)
|
|
1115
1474
|
"""
|
|
1116
1475
|
if not self._media_manager:
|
|
1117
1476
|
raise RuntimeError("Client must be used as async context manager")
|