marqetive-lib 0.1.17__py3-none-any.whl → 0.1.20__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/core/base.py +33 -1
- marqetive/core/models.py +2 -0
- marqetive/platforms/instagram/client.py +43 -6
- marqetive/platforms/linkedin/client.py +432 -42
- marqetive/platforms/linkedin/media.py +320 -211
- marqetive/platforms/tiktok/client.py +51 -10
- marqetive/platforms/twitter/client.py +66 -8
- {marqetive_lib-0.1.17.dist-info → marqetive_lib-0.1.20.dist-info}/METADATA +1 -1
- {marqetive_lib-0.1.17.dist-info → marqetive_lib-0.1.20.dist-info}/RECORD +10 -10
- {marqetive_lib-0.1.17.dist-info → marqetive_lib-0.1.20.dist-info}/WHEEL +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.
|
|
@@ -301,53 +319,165 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
301
319
|
except httpx.HTTPError:
|
|
302
320
|
return False
|
|
303
321
|
|
|
322
|
+
# ==================== Validation ====================
|
|
323
|
+
|
|
324
|
+
def _validate_create_post_request(self, request: PostCreateRequest) -> None:
|
|
325
|
+
"""Validate LinkedIn post creation request.
|
|
326
|
+
|
|
327
|
+
LinkedIn Requirements:
|
|
328
|
+
- Content is required (text post content)
|
|
329
|
+
- Content max 3000 characters
|
|
330
|
+
- CTA labels must be from approved list if provided
|
|
331
|
+
- Media: max 20 images, or 1 video, or 1 document
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
request: Post creation request to validate.
|
|
335
|
+
|
|
336
|
+
Raises:
|
|
337
|
+
ValidationError: If validation fails.
|
|
338
|
+
"""
|
|
339
|
+
if not request.content:
|
|
340
|
+
raise ValidationError(
|
|
341
|
+
"LinkedIn posts require content",
|
|
342
|
+
platform=self.platform_name,
|
|
343
|
+
field="content",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if len(request.content) > 3000:
|
|
347
|
+
raise ValidationError(
|
|
348
|
+
f"Post content exceeds 3000 characters ({len(request.content)} characters)",
|
|
349
|
+
platform=self.platform_name,
|
|
350
|
+
field="content",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Validate CTA label if provided
|
|
354
|
+
if cta := request.additional_data.get("call_to_action"):
|
|
355
|
+
cta_upper = cta.upper()
|
|
356
|
+
if cta_upper not in VALID_CTA_LABELS:
|
|
357
|
+
raise ValidationError(
|
|
358
|
+
f"Invalid call_to_action: '{cta}'. "
|
|
359
|
+
f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
|
|
360
|
+
platform=self.platform_name,
|
|
361
|
+
field="call_to_action",
|
|
362
|
+
)
|
|
363
|
+
|
|
304
364
|
# ==================== Post CRUD Methods ====================
|
|
305
365
|
|
|
306
366
|
async def create_post(self, request: PostCreateRequest) -> Post:
|
|
307
367
|
"""Create and publish a LinkedIn post.
|
|
308
368
|
|
|
309
369
|
Uses the Community Management API to create posts on personal profiles
|
|
310
|
-
or organization pages.
|
|
370
|
+
or organization pages. Supports text, images, videos, documents,
|
|
371
|
+
multi-image posts, articles, and Direct Sponsored Content (dark posts).
|
|
311
372
|
|
|
312
373
|
Args:
|
|
313
|
-
request: Post creation request
|
|
314
|
-
|
|
374
|
+
request: Post creation request with the following fields:
|
|
375
|
+
- content: Post text (max 3000 chars). Supports mentions and hashtags.
|
|
376
|
+
- link: URL for article posts.
|
|
377
|
+
- media_ids: List of media URNs (images, videos, documents).
|
|
378
|
+
- additional_data: LinkedIn-specific options (see below).
|
|
379
|
+
|
|
380
|
+
Additional Data Options:
|
|
381
|
+
Basic options:
|
|
382
|
+
- visibility: "PUBLIC" (default), "CONNECTIONS", "LOGGED_IN"
|
|
383
|
+
- feed_distribution: "MAIN_FEED" (default), "NONE" (for dark posts)
|
|
384
|
+
- disable_reshare: bool (default False)
|
|
385
|
+
|
|
386
|
+
Call-to-action:
|
|
387
|
+
- call_to_action: CTA label (APPLY, DOWNLOAD, VIEW_QUOTE, LEARN_MORE,
|
|
388
|
+
SIGN_UP, SUBSCRIBE, REGISTER, JOIN, ATTEND, REQUEST_DEMO,
|
|
389
|
+
SEE_MORE, BUY_NOW, SHOP_NOW)
|
|
390
|
+
- landing_page: URL for CTA button
|
|
391
|
+
|
|
392
|
+
Media options:
|
|
393
|
+
- media_title: Title for single media
|
|
394
|
+
- media_alt_text: Alt text for single media
|
|
395
|
+
- media_alt_texts: List of alt texts for multi-image posts
|
|
396
|
+
|
|
397
|
+
Article options:
|
|
398
|
+
- article_title: Title for link preview
|
|
399
|
+
- article_description: Description for link preview
|
|
400
|
+
- article_thumbnail: Image URN for article thumbnail
|
|
401
|
+
|
|
402
|
+
Targeting (for targeted posts):
|
|
403
|
+
- target_entities: List of targeting criteria
|
|
404
|
+
|
|
405
|
+
Direct Sponsored Content (dark posts):
|
|
406
|
+
- ad_context: Dict with DSC configuration:
|
|
407
|
+
- is_dsc: True for dark posts
|
|
408
|
+
- dsc_ad_type: VIDEO, STANDARD, CAROUSEL, JOB_POSTING,
|
|
409
|
+
NATIVE_DOCUMENT, EVENT
|
|
410
|
+
- dsc_status: ACTIVE, ARCHIVED
|
|
411
|
+
- dsc_ad_account: Sponsored account URN
|
|
412
|
+
- dsc_name: Display name for the DSC
|
|
413
|
+
|
|
414
|
+
Mentions and Hashtags:
|
|
415
|
+
Use these formats in the content field:
|
|
416
|
+
- Organization mention: @[Display Name](urn:li:organization:12345)
|
|
417
|
+
- Person mention: @[Jane Smith](urn:li:person:abc123)
|
|
418
|
+
- Hashtag: #coding (plain text, auto-formatted by LinkedIn)
|
|
419
|
+
|
|
420
|
+
Note: Organization names in mentions must match exactly (case-sensitive).
|
|
315
421
|
|
|
316
422
|
Returns:
|
|
317
423
|
Created Post object.
|
|
318
424
|
|
|
319
425
|
Raises:
|
|
320
|
-
ValidationError: If request is invalid.
|
|
426
|
+
ValidationError: If request is invalid or CTA label is invalid.
|
|
321
427
|
MediaUploadError: If media upload fails.
|
|
322
428
|
|
|
323
429
|
Example:
|
|
430
|
+
>>> # Text post with link and CTA
|
|
324
431
|
>>> request = PostCreateRequest(
|
|
325
432
|
... content="Check out our new product!",
|
|
326
433
|
... link="https://example.com/product",
|
|
327
434
|
... additional_data={
|
|
328
435
|
... "visibility": "PUBLIC",
|
|
329
|
-
... "call_to_action": "LEARN_MORE"
|
|
436
|
+
... "call_to_action": "LEARN_MORE",
|
|
437
|
+
... "landing_page": "https://example.com/signup"
|
|
330
438
|
... }
|
|
331
439
|
... )
|
|
332
440
|
>>> post = await client.create_post(request)
|
|
441
|
+
>>>
|
|
442
|
+
>>> # Multi-image post
|
|
443
|
+
>>> img1 = await client.upload_image("photo1.jpg")
|
|
444
|
+
>>> img2 = await client.upload_image("photo2.jpg")
|
|
445
|
+
>>> request = PostCreateRequest(
|
|
446
|
+
... content="Check out these photos!",
|
|
447
|
+
... media_ids=[img1.asset_id, img2.asset_id],
|
|
448
|
+
... additional_data={
|
|
449
|
+
... "media_alt_texts": ["First photo", "Second photo"]
|
|
450
|
+
... }
|
|
451
|
+
... )
|
|
452
|
+
>>> post = await client.create_post(request)
|
|
453
|
+
>>>
|
|
454
|
+
>>> # Dark post (Direct Sponsored Content)
|
|
455
|
+
>>> request = PostCreateRequest(
|
|
456
|
+
... content="Sponsored content",
|
|
457
|
+
... media_ids=[video_asset.asset_id],
|
|
458
|
+
... additional_data={
|
|
459
|
+
... "ad_context": {
|
|
460
|
+
... "is_dsc": True,
|
|
461
|
+
... "dsc_ad_type": "VIDEO",
|
|
462
|
+
... "dsc_status": "ACTIVE",
|
|
463
|
+
... "dsc_ad_account": "urn:li:sponsoredAccount:123"
|
|
464
|
+
... }
|
|
465
|
+
... }
|
|
466
|
+
... )
|
|
467
|
+
>>> post = await client.create_post(request)
|
|
468
|
+
>>>
|
|
469
|
+
>>> # Post with mentions and hashtags
|
|
470
|
+
>>> request = PostCreateRequest(
|
|
471
|
+
... content="Excited to announce our partnership with "
|
|
472
|
+
... "@[LinkedIn](urn:li:organization:1337)! #exciting #partnership"
|
|
473
|
+
... )
|
|
474
|
+
>>> post = await client.create_post(request)
|
|
333
475
|
"""
|
|
334
476
|
if not self.api_client:
|
|
335
477
|
raise RuntimeError("Client must be used as async context manager")
|
|
336
478
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
"LinkedIn posts require content",
|
|
340
|
-
platform=self.platform_name,
|
|
341
|
-
field="content",
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
# Validate content length (3000 characters for posts)
|
|
345
|
-
if len(request.content) > 3000:
|
|
346
|
-
raise ValidationError(
|
|
347
|
-
f"Post content exceeds 3000 characters ({len(request.content)} characters)",
|
|
348
|
-
platform=self.platform_name,
|
|
349
|
-
field="content",
|
|
350
|
-
)
|
|
479
|
+
# Validate request
|
|
480
|
+
self._validate_create_post_request(request)
|
|
351
481
|
|
|
352
482
|
try:
|
|
353
483
|
# Build REST API payload structure
|
|
@@ -370,23 +500,37 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
370
500
|
),
|
|
371
501
|
}
|
|
372
502
|
|
|
373
|
-
# Add media content if provided
|
|
503
|
+
# Add media content if provided (images, videos, or documents)
|
|
374
504
|
if request.media_ids:
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
"
|
|
381
|
-
|
|
505
|
+
if len(request.media_ids) > 1:
|
|
506
|
+
# Multi-image post (organic only, not for sponsored content)
|
|
507
|
+
# Per docs: use "multiImage" content type with array of images
|
|
508
|
+
images = []
|
|
509
|
+
for media_id in request.media_ids:
|
|
510
|
+
image_entry: dict[str, Any] = {"id": media_id}
|
|
511
|
+
# Alt text can be provided per image via additional_data
|
|
512
|
+
alt_texts = request.additional_data.get("media_alt_texts", [])
|
|
513
|
+
if alt_texts and len(alt_texts) > len(images):
|
|
514
|
+
image_entry["altText"] = alt_texts[len(images)]
|
|
515
|
+
images.append(image_entry)
|
|
516
|
+
|
|
517
|
+
post_payload["content"] = {"multiImage": {"images": images}}
|
|
518
|
+
else:
|
|
519
|
+
# Single media (image, video, or document)
|
|
520
|
+
media_id = request.media_ids[0]
|
|
521
|
+
post_payload["content"] = {
|
|
522
|
+
"media": {
|
|
523
|
+
"id": media_id,
|
|
524
|
+
"title": request.additional_data.get("media_title"),
|
|
525
|
+
"altText": request.additional_data.get("media_alt_text"),
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
# Remove None values
|
|
529
|
+
post_payload["content"]["media"] = {
|
|
530
|
+
k: v
|
|
531
|
+
for k, v in post_payload["content"]["media"].items()
|
|
532
|
+
if v is not None
|
|
382
533
|
}
|
|
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
534
|
|
|
391
535
|
# Add article/link if provided (and no media)
|
|
392
536
|
elif request.link:
|
|
@@ -411,10 +555,40 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
411
555
|
|
|
412
556
|
# Add call-to-action if provided
|
|
413
557
|
if cta := request.additional_data.get("call_to_action"):
|
|
414
|
-
|
|
558
|
+
cta_upper = cta.upper()
|
|
559
|
+
if cta_upper not in VALID_CTA_LABELS:
|
|
560
|
+
raise ValidationError(
|
|
561
|
+
f"Invalid call_to_action: '{cta}'. "
|
|
562
|
+
f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
|
|
563
|
+
platform=self.platform_name,
|
|
564
|
+
field="call_to_action",
|
|
565
|
+
)
|
|
566
|
+
post_payload["contentCallToActionLabel"] = cta_upper
|
|
415
567
|
if landing_page := request.additional_data.get("landing_page"):
|
|
416
568
|
post_payload["contentLandingPage"] = landing_page
|
|
417
569
|
|
|
570
|
+
# Add adContext for Direct Sponsored Content (DSC) / dark posts
|
|
571
|
+
# Dark posts don't appear on company page but can be used in ad campaigns
|
|
572
|
+
if ad_context := request.additional_data.get("ad_context"):
|
|
573
|
+
post_payload["adContext"] = {}
|
|
574
|
+
if ad_context.get("is_dsc"):
|
|
575
|
+
post_payload["adContext"]["isDsc"] = True
|
|
576
|
+
if dsc_ad_type := ad_context.get("dsc_ad_type"):
|
|
577
|
+
# Valid types: VIDEO, STANDARD, CAROUSEL, JOB_POSTING,
|
|
578
|
+
# NATIVE_DOCUMENT, EVENT
|
|
579
|
+
post_payload["adContext"]["dscAdType"] = dsc_ad_type
|
|
580
|
+
if dsc_status := ad_context.get("dsc_status"):
|
|
581
|
+
# Valid values: ACTIVE, ARCHIVED
|
|
582
|
+
post_payload["adContext"]["dscStatus"] = dsc_status
|
|
583
|
+
if dsc_ad_account := ad_context.get("dsc_ad_account"):
|
|
584
|
+
post_payload["adContext"]["dscAdAccount"] = dsc_ad_account
|
|
585
|
+
if dsc_name := ad_context.get("dsc_name"):
|
|
586
|
+
post_payload["adContext"]["dscName"] = dsc_name
|
|
587
|
+
|
|
588
|
+
# For dark posts, set feedDistribution to NONE
|
|
589
|
+
if ad_context.get("is_dsc"):
|
|
590
|
+
post_payload["distribution"]["feedDistribution"] = "NONE"
|
|
591
|
+
|
|
418
592
|
# Create the post
|
|
419
593
|
response = await self.api_client.post("/posts", data=post_payload)
|
|
420
594
|
|
|
@@ -434,6 +608,7 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
434
608
|
status=PostStatus.PUBLISHED,
|
|
435
609
|
created_at=datetime.now(),
|
|
436
610
|
author_id=self.author_urn,
|
|
611
|
+
url=None, # Not available without separate fetch
|
|
437
612
|
raw_data=response.data,
|
|
438
613
|
)
|
|
439
614
|
|
|
@@ -534,7 +709,15 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
534
709
|
# Handle additional LinkedIn-specific fields
|
|
535
710
|
additional = getattr(request, "additional_data", {}) or {}
|
|
536
711
|
if cta := additional.get("call_to_action"):
|
|
537
|
-
|
|
712
|
+
cta_upper = cta.upper()
|
|
713
|
+
if cta_upper not in VALID_CTA_LABELS:
|
|
714
|
+
raise ValidationError(
|
|
715
|
+
f"Invalid call_to_action: '{cta}'. "
|
|
716
|
+
f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
|
|
717
|
+
platform=self.platform_name,
|
|
718
|
+
field="call_to_action",
|
|
719
|
+
)
|
|
720
|
+
patch_payload["patch"]["$set"]["contentCallToActionLabel"] = cta_upper
|
|
538
721
|
if landing_page := additional.get("landing_page"):
|
|
539
722
|
patch_payload["patch"]["$set"]["contentLandingPage"] = landing_page
|
|
540
723
|
if lifecycle := additional.get("lifecycle_state"):
|
|
@@ -597,9 +780,13 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
597
780
|
raise RuntimeError("API client not initialized")
|
|
598
781
|
|
|
599
782
|
encoded_post_id = quote(post_id, safe="")
|
|
783
|
+
headers = {
|
|
784
|
+
**self._build_auth_headers(),
|
|
785
|
+
"X-RestLi-Method": "DELETE",
|
|
786
|
+
}
|
|
600
787
|
await self.api_client._client.delete(
|
|
601
788
|
f"{self.base_url}/posts/{encoded_post_id}",
|
|
602
|
-
headers=
|
|
789
|
+
headers=headers,
|
|
603
790
|
)
|
|
604
791
|
return True
|
|
605
792
|
|
|
@@ -693,6 +880,204 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
693
880
|
platform=self.platform_name,
|
|
694
881
|
) from e
|
|
695
882
|
|
|
883
|
+
async def create_reshare(
|
|
884
|
+
self,
|
|
885
|
+
parent_post_urn: str,
|
|
886
|
+
commentary: str | None = None,
|
|
887
|
+
visibility: str = "PUBLIC",
|
|
888
|
+
) -> Post:
|
|
889
|
+
"""Create a reshare (repost) of an existing LinkedIn post.
|
|
890
|
+
|
|
891
|
+
Args:
|
|
892
|
+
parent_post_urn: URN of the post to reshare (urn:li:share:xxx or urn:li:ugcPost:xxx).
|
|
893
|
+
commentary: Optional commentary to add to the reshare.
|
|
894
|
+
visibility: Post visibility (PUBLIC, CONNECTIONS, LOGGED_IN).
|
|
895
|
+
|
|
896
|
+
Returns:
|
|
897
|
+
Created Post object.
|
|
898
|
+
|
|
899
|
+
Raises:
|
|
900
|
+
ValidationError: If parent_post_urn is invalid.
|
|
901
|
+
|
|
902
|
+
Example:
|
|
903
|
+
>>> reshare = await client.create_reshare(
|
|
904
|
+
... "urn:li:share:6957408550713184256",
|
|
905
|
+
... commentary="Great insights!"
|
|
906
|
+
... )
|
|
907
|
+
"""
|
|
908
|
+
if not self.api_client:
|
|
909
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
post_payload: dict[str, Any] = {
|
|
913
|
+
"author": self.author_urn,
|
|
914
|
+
"visibility": visibility,
|
|
915
|
+
"distribution": {
|
|
916
|
+
"feedDistribution": "MAIN_FEED",
|
|
917
|
+
"targetEntities": [],
|
|
918
|
+
"thirdPartyDistributionChannels": [],
|
|
919
|
+
},
|
|
920
|
+
"lifecycleState": "PUBLISHED",
|
|
921
|
+
"reshareContext": {
|
|
922
|
+
"parent": parent_post_urn,
|
|
923
|
+
},
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if commentary:
|
|
927
|
+
post_payload["commentary"] = commentary
|
|
928
|
+
|
|
929
|
+
response = await self.api_client.post("/posts", data=post_payload)
|
|
930
|
+
|
|
931
|
+
post_id = response.data.get("id") or response.headers.get("x-restli-id")
|
|
932
|
+
if not post_id:
|
|
933
|
+
raise PlatformError(
|
|
934
|
+
"Failed to get post ID from response",
|
|
935
|
+
platform=self.platform_name,
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
return Post(
|
|
939
|
+
post_id=post_id,
|
|
940
|
+
platform=self.platform_name,
|
|
941
|
+
content=commentary or "",
|
|
942
|
+
status=PostStatus.PUBLISHED,
|
|
943
|
+
created_at=datetime.now(),
|
|
944
|
+
author_id=self.author_urn,
|
|
945
|
+
raw_data=response.data,
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
except httpx.HTTPError as e:
|
|
949
|
+
raise PlatformError(
|
|
950
|
+
f"Failed to create reshare: {e}",
|
|
951
|
+
platform=self.platform_name,
|
|
952
|
+
) from e
|
|
953
|
+
|
|
954
|
+
async def batch_get_posts(self, post_ids: list[str]) -> list[Post]:
|
|
955
|
+
"""Retrieve multiple posts by their URNs in a single request.
|
|
956
|
+
|
|
957
|
+
Uses the BATCH_GET method for efficient retrieval of multiple posts.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
post_ids: List of post URNs (urn:li:share:xxx or urn:li:ugcPost:xxx).
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
List of Post objects (in the same order as input if available).
|
|
964
|
+
|
|
965
|
+
Example:
|
|
966
|
+
>>> posts = await client.batch_get_posts([
|
|
967
|
+
... "urn:li:share:123",
|
|
968
|
+
... "urn:li:ugcPost:456"
|
|
969
|
+
... ])
|
|
970
|
+
"""
|
|
971
|
+
if not self.api_client:
|
|
972
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
973
|
+
|
|
974
|
+
if not self.api_client._client:
|
|
975
|
+
raise RuntimeError("API client not initialized")
|
|
976
|
+
|
|
977
|
+
if not post_ids:
|
|
978
|
+
return []
|
|
979
|
+
|
|
980
|
+
try:
|
|
981
|
+
# URL encode each post ID
|
|
982
|
+
encoded_ids = [quote(pid, safe="") for pid in post_ids]
|
|
983
|
+
ids_param = f"List({','.join(encoded_ids)})"
|
|
984
|
+
|
|
985
|
+
headers = {
|
|
986
|
+
**self._build_auth_headers(),
|
|
987
|
+
"X-RestLi-Method": "BATCH_GET",
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
response = await self.api_client._client.get(
|
|
991
|
+
f"{self.base_url}/posts",
|
|
992
|
+
params={"ids": ids_param},
|
|
993
|
+
headers=headers,
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
data = response.json()
|
|
997
|
+
results = data.get("results", {})
|
|
998
|
+
|
|
999
|
+
# Parse posts, maintaining order where possible
|
|
1000
|
+
posts = []
|
|
1001
|
+
for post_id in post_ids:
|
|
1002
|
+
if post_id in results:
|
|
1003
|
+
posts.append(self._parse_post(results[post_id]))
|
|
1004
|
+
|
|
1005
|
+
return posts
|
|
1006
|
+
|
|
1007
|
+
except httpx.HTTPError as e:
|
|
1008
|
+
raise PlatformError(
|
|
1009
|
+
f"Failed to batch get posts: {e}",
|
|
1010
|
+
platform=self.platform_name,
|
|
1011
|
+
) from e
|
|
1012
|
+
|
|
1013
|
+
async def list_dsc_posts(
|
|
1014
|
+
self,
|
|
1015
|
+
dsc_ad_account: str,
|
|
1016
|
+
dsc_ad_types: list[str] | None = None,
|
|
1017
|
+
limit: int = 10,
|
|
1018
|
+
offset: int = 0,
|
|
1019
|
+
) -> list[Post]:
|
|
1020
|
+
"""List Direct Sponsored Content (DSC) posts by ad account.
|
|
1021
|
+
|
|
1022
|
+
Args:
|
|
1023
|
+
dsc_ad_account: Sponsored account URN (urn:li:sponsoredAccount:xxx).
|
|
1024
|
+
dsc_ad_types: Optional filter by DSC types (VIDEO, STANDARD, CAROUSEL,
|
|
1025
|
+
JOB_POSTING, NATIVE_DOCUMENT, EVENT).
|
|
1026
|
+
limit: Maximum number of posts to retrieve (max 100).
|
|
1027
|
+
offset: Number of posts to skip for pagination.
|
|
1028
|
+
|
|
1029
|
+
Returns:
|
|
1030
|
+
List of Post objects.
|
|
1031
|
+
|
|
1032
|
+
Example:
|
|
1033
|
+
>>> dsc_posts = await client.list_dsc_posts(
|
|
1034
|
+
... "urn:li:sponsoredAccount:520866471",
|
|
1035
|
+
... dsc_ad_types=["VIDEO", "STANDARD"]
|
|
1036
|
+
... )
|
|
1037
|
+
"""
|
|
1038
|
+
if not self.api_client:
|
|
1039
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
1040
|
+
|
|
1041
|
+
if not self.api_client._client:
|
|
1042
|
+
raise RuntimeError("API client not initialized")
|
|
1043
|
+
|
|
1044
|
+
try:
|
|
1045
|
+
encoded_account = quote(dsc_ad_account, safe="")
|
|
1046
|
+
|
|
1047
|
+
params: dict[str, Any] = {
|
|
1048
|
+
"dscAdAccount": encoded_account,
|
|
1049
|
+
"q": "dscAdAccount",
|
|
1050
|
+
"count": min(limit, 100),
|
|
1051
|
+
"start": offset,
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if dsc_ad_types:
|
|
1055
|
+
params["dscAdTypes"] = f"List({','.join(dsc_ad_types)})"
|
|
1056
|
+
|
|
1057
|
+
headers = {
|
|
1058
|
+
**self._build_auth_headers(),
|
|
1059
|
+
"X-RestLi-Method": "FINDER",
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
response = await self.api_client._client.get(
|
|
1063
|
+
f"{self.base_url}/posts",
|
|
1064
|
+
params=params,
|
|
1065
|
+
headers=headers,
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
posts = []
|
|
1069
|
+
data = response.json()
|
|
1070
|
+
for post_data in data.get("elements", []):
|
|
1071
|
+
posts.append(self._parse_post(post_data))
|
|
1072
|
+
|
|
1073
|
+
return posts
|
|
1074
|
+
|
|
1075
|
+
except httpx.HTTPError as e:
|
|
1076
|
+
raise PlatformError(
|
|
1077
|
+
f"Failed to list DSC posts: {e}",
|
|
1078
|
+
platform=self.platform_name,
|
|
1079
|
+
) from e
|
|
1080
|
+
|
|
696
1081
|
# ==================== Comment Methods ====================
|
|
697
1082
|
|
|
698
1083
|
async def get_comments(
|
|
@@ -1098,20 +1483,25 @@ class LinkedInClient(SocialMediaPlatform):
|
|
|
1098
1483
|
*,
|
|
1099
1484
|
title: str | None = None,
|
|
1100
1485
|
) -> MediaAsset:
|
|
1101
|
-
"""Upload a document
|
|
1486
|
+
"""Upload a document to LinkedIn using the Documents API.
|
|
1102
1487
|
|
|
1103
|
-
Convenience method for document uploads.
|
|
1488
|
+
Convenience method for document uploads. Supports PDF, PPT, PPTX, DOC, DOCX.
|
|
1104
1489
|
|
|
1105
1490
|
Args:
|
|
1106
|
-
file_path: Path to
|
|
1107
|
-
title: Document title.
|
|
1491
|
+
file_path: Path to document file or URL.
|
|
1492
|
+
title: Document title (reserved for future use).
|
|
1108
1493
|
|
|
1109
1494
|
Returns:
|
|
1110
|
-
MediaAsset with
|
|
1495
|
+
MediaAsset with document URN (urn:li:document:xxx).
|
|
1111
1496
|
|
|
1112
1497
|
Example:
|
|
1113
1498
|
>>> async with LinkedInClient(credentials) as client:
|
|
1114
1499
|
... asset = await client.upload_document("report.pdf")
|
|
1500
|
+
... request = PostCreateRequest(
|
|
1501
|
+
... content="Check out our report!",
|
|
1502
|
+
... media_ids=[asset.asset_id]
|
|
1503
|
+
... )
|
|
1504
|
+
... post = await client.create_post(request)
|
|
1115
1505
|
"""
|
|
1116
1506
|
if not self._media_manager:
|
|
1117
1507
|
raise RuntimeError("Client must be used as async context manager")
|